Next.js App Router SEO: Metadata, Sitemap, JSON-LD and llms.txt

TQ
Tran Quang
October 30, 2026 · 7 min read
#Next.js#SEO#JSON-LD#Web

The Next.js App Router makes good technical SEO largely a matter of conventions: a metadata API, file-based sitemap.ts and robots.ts, and server components that render real HTML. This guide walks through a setup that covers the parts search engines and AI assistants actually use: titles and descriptions, canonicals, Open Graph, structured data, sitemaps, robots rules and an llms.txt file. The code reflects the approach used on this site.

Start with a site-wide config

Keep the canonical origin and shared text in one module so every page builds absolute URLs the same way:

ts
// src/config/site.config.ts
export const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.example.com").replace(/\/$/, "");

export const SITE_NAME = "Example - Portfolio";

/** Absolute URL for a path or an already-absolute URL. */
export const absoluteUrl = (pathOrUrl: string) =>
  /^https?:\/\//.test(pathOrUrl) ? pathOrUrl : `${SITE_URL}${pathOrUrl.startsWith("/") ? "" : "/"}${pathOrUrl}`;

Pick one canonical host (with or without www) and redirect the other to it with a permanent redirect. Mixed hosts split ranking signals.

Metadata in the root layout

Set defaults once in app/layout.tsx and override per page:

ts
import type { Metadata } from "next";
import { SITE_URL } from "../config/site.config";

export const metadata: Metadata = {
  metadataBase: new URL(SITE_URL),
  title: {
    template: "%s | Example",
    default: "Example - Software Engineer",
  },
  description: "One clear sentence that says who you are and what you do.",
  alternates: {
    canonical: "/",
    types: { "application/rss+xml": [{ url: "/feed.xml", title: "Blog feed" }] },
  },
  openGraph: { type: "website", siteName: "Example", locale: "en_US" },
  twitter: { card: "summary_large_image" },
  robots: {
    index: true,
    follow: true,
    googleBot: { "max-image-preview": "large", "max-snippet": -1 },
  },
};

metadataBase lets you write relative URLs in the rest of the metadata and have Next.js turn them into absolute ones.

A detail that catches people out: metadata fields replace parents rather than merge. If a page sets its own alternates, it overrides the layout's alternates, including the RSS link. Repeat any field you still want on that page.

Per-page and dynamic metadata

For static pages export metadata. For content-driven pages such as blog posts use generateMetadata:

ts
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
  const { slug } = await params;
  const post = posts.find((p) => p.slug === slug);
  if (!post) return { title: "Post not found", robots: { index: false } };

  return {
    title: { absolute: post.seoTitle ?? post.title },
    description: post.summary,
    alternates: { canonical: `/blog/${post.slug}` },
    openGraph: {
      type: "article",
      url: `/blog/${post.slug}`,
      title: post.title,
      description: post.summary,
      publishedTime: post.publishedAt,
      modifiedTime: post.updatedAt,
      images: [{ url: post.image, width: 1200, height: 630, alt: post.title }],
    },
  };
}

Practical rules for titles and descriptions:

  • Titles: keep them near 50 to 60 characters, with the main topic first.
  • Descriptions: write for humans, aim for roughly 150 to 160 characters and make each page's description unique.
  • Canonical: self-referencing on every indexable page.
  • Not found pages: return a real 404 status and noindex.

A small helper can trim descriptions at sentence boundaries and drop the brand suffix when a title is already long, so you never publish a cut-off snippet.

Sitemap

Create app/sitemap.ts. Next.js serves it at /sitemap.xml:

ts
import type { MetadataRoute } from "next";
import { SITE_URL } from "../config/site.config";

export default function sitemap(): MetadataRoute.Sitemap {
  const pages = ["/", "/about", "/blog", "/contact"].map((path) => ({
    url: `${SITE_URL}${path}`,
    lastModified: new Date(), // replace with the real modified date
  }));

  const posts = allPosts.map((post) => ({
    url: `${SITE_URL}/blog/${post.slug}`,
    lastModified: new Date(post.updatedAt),
  }));

  return [...pages, ...posts];
}

Guidelines:

  • List only canonical, indexable URLs that return 200.
  • Use accurate lastModified values. Search engines learn to ignore dates that always change.
  • changeFrequency and priority are optional and Google ignores them, so you can leave them out.
  • Reference the sitemap from robots.txt and submit it in Google Search Console and Bing Webmaster Tools.

robots.txt, including AI crawlers

ts
// app/robots.ts
import type { MetadataRoute } from "next";
import { SITE_URL } from "../config/site.config";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [{ userAgent: "*", allow: "/", disallow: ["/api/"] }],
    sitemap: `${SITE_URL}/sitemap.xml`,
  };
}

Do not block /_next/ because search engines need your scripts and styles to render pages. If you want to be cited by AI assistants, do not block their crawlers; if you want to opt out of AI training but stay in search, decide per user agent. These bots serve different purposes (search, user-triggered fetches, training), so read each vendor's documentation before listing them.

Structured data with JSON-LD

Render JSON-LD as a script tag in a server component. Describe the person or organisation once with an @id and refer to it from other nodes:

tsx
const siteSchema = {
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "Person",
      "@id": `${SITE_URL}/#person`,
      name: "Your Name",
      url: SITE_URL,
      jobTitle: "Software Engineer",
      sameAs: ["https://github.com/you", "https://www.linkedin.com/in/you/"],
    },
    {
      "@type": "WebSite",
      "@id": `${SITE_URL}/#website`,
      url: SITE_URL,
      name: "Example",
      publisher: { "@id": `${SITE_URL}/#person` },
    },
  ],
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(siteSchema) }} />
        {children}
      </body>
    </html>
  );
}

On article pages add BlogPosting with headline, datePublished, dateModified, image and author: { "@id": ... }, plus a BreadcrumbList. Notes:

  • Describe only what is visible on the page.
  • Use ProfilePage, CollectionPage and ProfessionalService where they honestly fit.
  • Validate with the Schema Markup Validator and Google's Rich Results Test.
  • Do not add HowTo (retired), and do not expect FAQPage to produce rich results anymore; Google stopped showing them. Use clear headings and visible Q&A text instead.

Make the HTML itself useful

Search engines and AI crawlers read HTML before running JavaScript, so server rendering is your friend:

  • One h1 per page and a logical heading hierarchy.
  • Meaningful link text and real <a href> links between related pages.
  • alt text on content images, and next/image for sizing and modern formats.
  • Keep the HTML payload reasonable. Passing large arrays from a server component to a client component serialises them into the page. Pass only what the client needs, and fetch or paginate the rest.

Add llms.txt for AI assistants

llms.txt is a simple Markdown file that summarises your site for language models. It is a proposed convention and not a ranking factor, but it is cheap to generate from the same data:

ts
// app/llms.txt/route.ts
export const dynamic = "force-static";

export function GET() {
  const body = `# Your Name

> Software engineer in Ho Chi Minh City, Vietnam. Short factual summary.

## Blog

${posts.map((p) => `- [${p.title}](${SITE_URL}/blog/${p.slug}): ${p.summary}`).join("\n")}
`;
  return new Response(body, { headers: { "Content-Type": "text/markdown; charset=utf-8" } });
}

For AI answers, what matters most is still the page content itself: direct answers near the top of each section, specific facts, named entities, dates and clear authorship.

Verify before you ship

  1. next build and next start, then request the pages with curl and read the real HTML.
  2. Check <title>, description, canonical and JSON-LD on each page type.
  3. Open /sitemap.xml, /robots.txt and /llms.txt.
  4. Validate structured data and run Lighthouse for performance and accessibility.
  5. After deploying, submit the sitemap and use URL Inspection in Search Console on a few pages.

Technical SEO checklist for Next.js

  • One canonical host and permanent redirects
  • metadataBase, title template and unique descriptions
  • Self-referencing canonicals on every page
  • sitemap.ts with accurate dates and robots.ts pointing to it
  • JSON-LD graph with shared @id nodes
  • Real 404 for missing pages
  • Reasonable HTML size and server-rendered content
  • Open Graph images at 1200 x 630
  • Security headers and fast Core Web Vitals

Conclusion

Next.js gives you the building blocks for strong technical SEO: the metadata API, file-based sitemap and robots, server-rendered HTML and easy JSON-LD. Keep one source of truth for URLs, give every page a unique title, description and canonical, link entities together with @id, and verify the real HTML after each build. If you want a technical SEO review of a Next.js site, see the services page.

References

Found this useful?

Share it with someone who might need it.

Related articles All articles