Next.js technical SEO:

a checklist for the App Router

A Next.js SEO checklist for the App Router: metadata, canonicals, sitemaps, robots, rendering choices, structured data and letting AI crawlers in.

By Jawad Jalal 9 min read Search

All notes in Kindling
In this note11 sections
  1. The short version
  2. Metadata API and titles
  3. Canonicals and trailing slashes
  4. sitemap.ts and robots.ts
  5. Server vs client rendering and what crawlers see
  6. JSON-LD in layouts and pages
  7. Images and Core Web Vitals
  8. AI crawler access and llms.txt
  9. How to check your Next.js site
  10. FAQ
  11. Want a second pair of eyes on your Next.js setup?

Next.js gives you most of what technical SEO needs out of the box: server rendering, a Metadata API, and file conventions for sitemap.xml and robots.txt. What it doesn’t do is pick your canonicals, titles or crawler rules for you, so the checklist below is mostly about setting those on purpose.

Each section links to the Next.js or Google doc it comes from, and the code follows the Next.js docs. It assumes the App Router.

The short version

  • Set metadataBase, a title template and a default description in the root layout.
  • Give every indexable page its own title, description and canonical.
  • Pick one URL form (with or without a trailing slash) and keep canonicals, sitemap and internal links on it.
  • Add app/sitemap.ts and app/robots.ts.
  • Keep the content you want found in Server Components, so it is in the HTML.
  • Make missing pages return a real 404.
  • Render JSON-LD in the page or layout, escaped.
  • Use next/image with sizes set, and preload only the LCP image.
  • Check your robots rules and firewall let the AI crawlers you want in.

Metadata API and titles

Titles and descriptions come from a metadata export or a generateMetadata function in a layout or page. Both only work in Server Components, because Next.js resolves metadata on the server so it can go in the initial HTML (Next.js).

Set the shared parts once in the root layout:

// app/layout.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  metadataBase: new URL('https://acme.com'),
  title: {
    template: '%s | Acme',
    default: 'Acme', // a default is required when creating a template
  },
}

A page that exports title: 'About' then renders <title>About | Acme</title>. Two details from the docs: the template applies to child segments, not the segment it is defined in, and title.absolute lets one page skip it.

For pages built from data, such as blog posts or docs, use generateMetadata and read the params. Google asks for “unique, descriptive” titles and meta descriptions on every page (Google), and a template wrapped around an empty or repeated page title breaks that on every page it touches. The SERP preview tool shows how a title and description will look before you ship them.

Streaming metadata. Since Next.js 15.2, generateMetadata can stream: the page renders without waiting for it, and the tags are added to the <body> when it resolves. Next.js says it has verified this works for bots that run JavaScript, such as Googlebot. For bots that only read HTML, it waits and puts metadata in the <head>. It picks between the two by user agent, using a default list you can change with htmlLimitedBots (Next.js). We come back to this in the AI crawler section.

Canonicals and trailing slashes

Set the canonical in alternates. With metadataBase set, a relative path is enough:

export const metadata = {
  metadataBase: new URL('https://acme.com'),
  alternates: {
    canonical: '/',
    languages: {
      'en-US': '/en-US',
      'de-DE': '/de-DE',
    },
  },
}

That outputs <link rel="canonical" href="https://acme.com" /> plus hreflang links. On dynamic routes, build the canonical from the params in generateMetadata so each page points at itself.

Two rules from Google worth knowing. Set the canonical in the HTML, and if JavaScript changes it, it must not change it to a different URL than the original HTML (Google). And canonicals are a signal, so keep your sitemap and internal links pointing at the same URLs (Google).

Trailing slashes are the usual source of duplicates. By default Next.js redirects /about/ to /about. Setting trailingSlash: true in next.config.js does the opposite (Next.js). Either is fine. Pick one and make canonicals, the sitemap and links match it.

sitemap.ts and robots.ts

Both are file conventions in the app folder. A sitemap (Next.js):

// app/sitemap.ts
import type { MetadataRoute } from 'next'

export default function sitemap(): MetadataRoute.Sitemap {
  return [
    {
      url: 'https://acme.com',
      lastModified: new Date(),
      changeFrequency: 'yearly',
      priority: 1,
    },
    {
      url: 'https://acme.com/blog',
      lastModified: new Date(),
      changeFrequency: 'weekly',
      priority: 0.5,
    },
  ]
}

In a real app you’d map over your posts or docs and use each one’s real updated date for lastModified, rather than new Date() on every build. For large sites, generateSitemaps splits the file: Google’s limit is 50,000 URLs per sitemap.

A robots file (Next.js):

// app/robots.ts
import type { MetadataRoute } from 'next'

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: '*',
      allow: '/',
      disallow: '/private/',
    },
    sitemap: 'https://acme.com/sitemap.xml',
  }
}

rules also takes an array, so you can give named crawlers their own rules. Use that for AI crawlers (see below), not a second robots.txt in public.

To keep a page out of the index, use robots: { index: false } in its metadata, not a Disallow line. A blocked page can’t be crawled, so Google never sees the noindex, and Google says robots.txt is not a way to keep a page out of its results (Google).

Server vs client rendering and what crawlers see

Google renders JavaScript. Pages with a 200 status go into a rendering queue, and a headless Chromium runs the JavaScript before Google indexes the rendered HTML. Google still says server-side rendering or pre-rendering “is still a great idea” because it is faster and “not all bots can run JavaScript” (Google).

In the App Router, Server Components are the default, so the main thing is not to undo that:

  • Keep body copy, headings and links in Server Components. A page that fetches its main content in a useEffect ships an empty shell to any bot that doesn’t run JavaScript.
  • Use real <a href> links. Next.js <Link> renders one. Google can only discover links that are <a> elements with an href, and can’t reliably follow fragment-based routing.
  • Return real status codes. Google uses them to tell a missing page from a real one.

Status codes and streaming. This one is easy to miss. A streamed response starts with a 200 status, and once the stream starts the status can’t change. If notFound() runs inside a Suspense boundary or after loading.tsx has rendered, the page keeps its 200 and Next.js adds <meta name="robots" content="noindex"> instead (Next.js). That keeps it out of search, but some crawlers may label it a soft 404. Call notFound() before anything streams, for example at the top of the page before any Suspense boundary, or check the slug in Proxy, and you get a real 404 (Next.js).

JSON-LD in layouts and pages

Next.js recommends rendering structured data as a <script> tag in layout.js or page.js (Next.js):

export default async function Page({ params }) {
  const { id } = await params
  const product = await getProduct(id)

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Product',
    name: product.name,
    image: product.image,
    description: product.description,
  }

  return (
    <section>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
        }}
      />
    </section>
  )
}

Keep the .replace(/</g, '\\u003c'). The docs point out that JSON.stringify alone doesn’t sanitize strings used in XSS injection. Put site-wide types like Organization in the root layout, and page types like Article, Product or FAQPage on the page. The schema should describe what is visible on that page.

Images and Core Web Vitals

Google’s Core Web Vitals targets are LCP under 2.5 seconds, INP under 200 milliseconds and CLS under 0.1 (Google). next/image helps with two of them if you set it up right (Next.js):

  • Give width and height (or fill with a sized parent) so the layout doesn’t shift as images load.
  • Set sizes for responsive images so phones don’t download desktop files.
  • Images are lazy by default. For the hero image that is your LCP element, use preload, or loading="eager" or fetchPriority="high", which the docs suggest in most cases. In Next.js 16, priority is deprecated in favour of preload.
  • Preload one image, not every image above the fold.

Check the results in Search Console’s Core Web Vitals report, which uses real user data, not a single lab run.

AI crawler access and llms.txt

AI search tools crawl your site with their own bots, such as OAI-SearchBot, Claude-SearchBot and PerplexityBot. Two things matter on a Next.js site.

Let them in. Check your app/robots.ts rules and your CDN or firewall. A firewall that blocks a bot overrides a robots.txt that allows it. The free AI crawler checker reads your live robots.txt and shows what it says to 12 AI crawlers. AI crawlers in robots.txt explains which bots do training, search or user fetches, with copy-paste rules.

Serve them HTML. Vercel’s analysis of AI crawler traffic on its network found that none of the major AI crawlers it looked at, including OpenAI’s and Anthropic’s, render JavaScript (Vercel). They read the HTML your server sends. Content in Server Components is there. Content fetched on the client isn’t.

Metadata needs a closer look. The default htmlLimitedBots list covers search and social bots such as Bingbot, Twitterbot and Slackbot, but not GPTBot, ClaudeBot or PerplexityBot. So those bots may get streamed metadata in the body, not the <head>. If you want them to get blocking metadata in the <head>, add them to htmlLimitedBots. Setting it replaces the default list, so copy the defaults in too.

llms.txt is optional. It is a proposed Markdown map of your site for agents. Google says one “will neither harm nor help your site’s visibility or rankings in Google Search” (Google). No major AI company has said its crawlers read the one on your site. It doesn’t allow or block anything either. If you want one, put it in public/llms.txt or serve it from a route handler. The llms.txt generator drafts one from your sitemap, and What is llms.txt covers what it does and doesn’t do.

Once crawlers can read your pages, how to get cited in AI answers covers what makes a page worth quoting.

How to check your Next.js site

  1. View source, not the inspector. Open a page with view-source or curl and look for the title, description, canonical, main copy and JSON-LD. If they are only in the rendered DOM, bots that don’t run JavaScript won’t see them.
  2. Use URL Inspection in Search Console to see the HTML Google rendered for a page.
  3. Request a URL that doesn’t exist and check the status is 404, not 200.
  4. Open /sitemap.xml and /robots.txt on production, and check they use your canonical host and slash form.
  5. Run the AI crawler checker against your domain.

FAQ

Is Next.js good for SEO?

Yes, if you keep content in Server Components. The App Router renders on the server by default, and the Metadata API, sitemap.ts and robots.ts cover the basics. The things to get right are settings, not the framework: canonicals, client-only content and soft 404s.

Does Next.js add canonical tags automatically?

Set them yourself with alternates.canonical in metadata or generateMetadata. Set metadataBase in the root layout so you can use relative paths, and build each dynamic page’s canonical from its params.

Should I use a trailing slash in Next.js?

Either works. By default Next.js redirects /about/ to /about, and trailingSlash: true reverses that. What matters is that canonicals, the sitemap and internal links all use the same form.

Can Google see client-rendered content in Next.js?

Usually, because Google renders JavaScript, but it goes through a rendering queue first. Many other bots, including the major AI crawlers in Vercel’s analysis, don’t run JavaScript at all. Keep content you want found in Server Components.

Why does my Next.js 404 page return a 200 status?

notFound() was called after the response had started streaming, often inside a Suspense boundary or under loading.tsx. Next.js then adds a noindex tag but can’t change the status. Call notFound() before anything streams to get a real 404.

Do I need llms.txt on a Next.js site?

No. It is optional. Google’s guide says one “will neither harm nor help your site’s visibility or rankings in Google Search”, and no major AI company has said its crawlers read it. If you want one, a static file in public is enough.

Want a second pair of eyes on your Next.js setup?

This is the code side of technical SEO. If you build developer tools, SEO for devtools covers how docs, changelogs and comparison pages fit around it. How we price the work is on the pricing page.

If you want us to look through your metadata, canonicals and crawler access with you, book a free 15-minute call.

Book your free Growth Check

Is this your growth problem?

Need a site that does this? 15 minutes, free, about your website.

Iggy

What’s your site missing? Tell me about your site and what you make, and I’ll say where I’d start.

Or skip to the call 15 minutes, free. Pick a time.

Finding the next open times.

Pick a day

Pick a time

Finding the open times.

Your details

After the call

  1. A free call about your site, with a plan and a price.
  2. The design, every page from scratch, until you sign it off.
  3. The build, live on your domain and yours to edit.

The invite and the video link come by email, with links to reschedule.

See you then.

The invite and the video link are on their way to .

What happens next

  1. A free call about your site, with a plan and a price.
  2. The design, every page from scratch, until you sign it off.
  3. The build, live on your domain and yours to edit.

On the call

You tell us what you’re making and who it’s for. We say which plan fits and when it could be live.

Or book on Cal.com, or email hello@ignura.com.