Technical SEO

Next.js SEO checklist for the App Router: 14 technical checks with code and curl

Fourteen checks for a Next.js App Router site, each with the code that gets it right and the curl command that proves it, including the streaming behaviours that turn 404s into 200s and move canonicals into the body.

By SEOFix TeamPublished Updated
1,690 words · 8 min readMarkdown
Next.js SEO checklist for the App Router: 14 technical checks with code and curl

Next.js SEO in the App Router comes down to a short list: the content is in the HTML the server sends, every page has its own title, description and absolute canonical from generateMetadata, missing pages return a real 404, moved pages return a single 308, robots.ts and sitemap.ts serve the right thing on the right host, and streaming doesn't undo any of it. The App Router gets most of this right by default. The failures are in the defaults that interact: loading.tsx with notFound(), slow generateMetadata with canonicals, Proxy with /robots.txt.

Below are the 14 checks we'd run on any App Router site, each with the code and a curl command to verify it. The behaviours marked "tested" were checked on a Next.js 16.4.2 production build on 2 November 2026. Each check links to a longer post where there is one.

1. The content is in the server HTML

Server Components render on the server, so their output is in the response. Client components that fetch in useEffect render an empty shell first. Google renders JavaScript, but it does so later, and most AI crawlers don't run it at all (Do AI crawlers render JavaScript?).

# words of real content in the raw HTML (before any JavaScript)
curl -s https://example.com/blog/hello-world | sed 's/<[^>]*>/ /g' | wc -w
# does a phrase from the article body appear?
curl -s https://example.com/blog/hello-world | grep -c 'a sentence from the article'

If the phrase isn't there, find the client component that loads it and move the fetch into a Server Component parent. SEOFix's on-page checks read this same raw HTML, and its render sample compares it with the rendered page (JavaScript rendering). Testing JavaScript crawlability covers comparing raw and rendered HTML in more depth.

2. Every page sets its own title and description

Put a template in the root layout and set title and description per page:

// app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL('https://example.com'),
  title: { default: 'Example', template: '%s · Example' },
  description: 'What the site does, in one sentence.',
}
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const post = await getPost((await params).slug)
  return { title: post.title, description: post.excerpt }
}

Two inheritance rules from the generateMetadata reference cause most duplicate-title reports. A page that doesn't set title uses its parent's, so every page you forgot gets the site name as its title. And metadata merges shallowly: a page that sets openGraph: { title } loses the layout's openGraph.images and openGraph.description entirely. Spread the shared parts into each page.

curl -s https://example.com/blog/hello-world | grep -o -E '<title>[^<]*</title>|<meta name="description"[^>]*>'

3. One absolute, self-referencing canonical per page

Set metadataBase once, then alternates: { canonical: '/path' } per page, built from the route and not the request, so ?utm_source= never ends up in it. Don't put a fixed canonical in a layout: every child page without its own inherits it, and in our test a layout's canonical: '/' made a child page canonicalise to the home page. Paginated pages keep ?page=n in their canonical, as Google's canonicalization guide and pagination guide ask.

The details, including what './' resolves to and the trailing-slash trap, are in Next.js canonical tag.

4. Metadata lands in the head for Googlebot

Since Next.js 15.2, generateMetadata can stream: its tags are "appended to the <body> tag" for browsers and for Googlebot, while bots on the htmlLimitedBots list (Bingbot, social previews) get them in <head> (docs). Tested: with a loading.tsx in the route, or a page that renders without waiting for the data generateMetadata needs, the canonical and title sat in the body for a Googlebot user agent. Google's canonicalization guide says the canonical "is only accepted if it appears in the <head>."

curl -s -A 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' https://example.com/blog/hello-world \
  | awk '/^\/head>/{print "---- end of head ----"} /rel="canonical"/{print "canonical here"}' RS='<'

If the canonical prints after "end of head", either let the page await the same cached data before rendering, or add Googlebot to htmlLimitedBots. Both are explained in the canonical post.

5. Missing pages return 404, not 200

notFound() sends a 404 only before the response starts streaming. Tested: a loading.tsx in the route or any parent folder, a <Suspense> boundary above the check, or a notFound() that only runs in generateMetadata all produced 200 with a noindex meta tag. The notFound docs confirm the mechanism: once streaming has started, the response "keeps its 200 status."

for p in /blog/zz-missing /products/zz-missing; do
  curl -s -o /dev/null -w "%{http_code} $p\n" "https://example.com$p"
done

Every dynamic template should print 404. The fixes (await the lookup first, share it with generateMetadata, or check in Proxy) are in Next.js 404 status code.

6. Outages return 5xx, not 404

The reverse bug is worse. A data function that returns null when the database times out, followed by if (!post) notFound(), tells Google that real pages are gone. Google removes indexed URLs that return 4xx, while 5xx only slows its crawling. Distinguish "not found" from "couldn't check":

async function getPost(slug: string) {
  const res = await fetch(`${API}/posts/${slug}`)
  if (res.status === 404) notFound()
  if (!res.ok) throw new Error(`posts API ${res.status}`) // error.tsx, 500
  return res.json()
}

7. Permanent moves are a single 308

permanent: true in next.config and permanentRedirect() send 308; redirect() and NextResponse.redirect() without a status send 307 (tested). Google treats 308 like 301. Write destinations in their final form, because Next.js's own trailing-slash redirect adds a hop to a destination like /new/ (tested: two 308s in a row).

curl -s -o /dev/null -L -w '%{num_redirects} hops, final %{http_code}: %{url_effective}\n' https://example.com/old-path

More, including redirects that turn into a meta refresh after streaming, in Next.js redirects.

8. One URL shape: trailing slash, host, protocol

By default Next.js redirects /about/ to /about; trailingSlash: true reverses it (docs). Pick one and make canonicals, sitemap URLs and internal links match it. Do http to https and www to apex at the CDN or reverse proxy, in one hop.

for u in http://example.com/about https://www.example.com/about https://example.com/about/; do
  curl -s -o /dev/null -L -w "%{num_redirects} hops -> %{url_effective}\n" "$u"
done

All three should end at the same URL in one hop.

9. robots.ts serves the right rules on the right host

app/robots.ts "is cached by default unless it uses a Request-time API or dynamic config option" (docs). Tested: an environment check inside it ran at build time, and a build made without the production variable served Disallow: / in production. Decide by the request's host with headers(), which makes the route dynamic.

curl -s https://example.com/robots.txt
curl -s https://staging.example.com/robots.txt

Code and the staging setup are in Next.js robots.txt.

10. Proxy leaves robots.txt and the sitemap alone

Proxy without a matcher "runs on every request" (Proxy docs). An auth or locale Proxy that redirects /robots.txt or /sitemap.xml breaks both files for crawlers. List the paths Proxy needs, or exclude the metadata files:

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon\\.ico|robots\\.txt|sitemap|llms\\.txt).*)'],
}
for f in robots.txt sitemap.xml; do
  curl -s -o /dev/null -w "%{http_code} %{content_type} %{redirect_url} $f\n" "https://example.com/$f"
done

Both should answer 200 with no redirect URL.

11. The sitemap lists only indexable 200 URLs, with real dates

app/sitemap.ts is cached the same way as robots.ts, so a database-driven sitemap can freeze at build time. Add revalidate, filter out noindex and redirected records, and use a content timestamp for lastModified, not new Date(). All of it, plus generateSitemaps for large sites, is in Next.js sitemap.

curl -s https://example.com/sitemap.xml | grep -c '<loc>'
curl -s https://example.com/sitemap.xml | grep -o '<lastmod>[^<]*' | sort | uniq -c | sort -rn | head -3

If the top lastmod value covers almost every URL, the dates aren't real.

12. Noindex where it belongs, and nowhere else

Use the robots metadata field for pages that should stay crawlable but out of results (internal search, thin filter combinations, login):

export const metadata: Metadata = { robots: { index: false, follow: true } }

For PDFs and other files, send X-Robots-Tag with the headers option (tested: the rule below added the header to /files/a.pdf and not to /files/b.txt) in next.config (headers docs):

async headers() {
  return [{ source: '/files/:path*.pdf', headers: [{ key: 'X-Robots-Tag', value: 'noindex' }] }]
}

Don't also disallow those URLs in robots.txt: Google's robots meta docs say the directives "can be read and followed only if crawlers are allowed to access the pages." And check that no layout sets robots: { index: false } for a whole section by accident:

curl -s https://example.com/pricing | grep -o '<meta name="robots"[^>]*>'
curl -sI https://example.com/pricing | grep -i x-robots-tag

Both should print nothing (or index, follow) on pages you want in search. Noindex vs disallow has the decision table.

Google "can only crawl your link if it's an <a> HTML element with an href attribute" (Google). next/link "extends the HTML <a> element" (Link docs), so <Link href> is fine. A <button onClick={() => router.push('/x')}> or a clickable <div> isn't a link to a crawler. Pagination and category navigation built that way leave pages without internal links.

next/image makes alt a required prop and says it describes the image "for screen readers and search engines" (Image docs). TypeScript enforces that it exists, not that it's useful: alt="" is right for decorative images and wrong for product photos.

# anchors without href, and images with empty alt, in the server HTML
curl -s https://example.com/ | grep -o '<a [^>]*>' | grep -vc 'href='
curl -s https://example.com/ | grep -o '<img [^>]*alt=""[^>]*>' | wc -l

14. Structured data is valid and escaped

Render JSON-LD as a native <script type="application/ld+json"> from a Server Component, serialised with JSON.stringify(data).replace(/</g, '\\u003c'), from the same record the page shows. Tested: without the replace, a </script> in a product name closed the tag early, leaving the rest to be parsed as a new script element. Component, examples and CI test in Next.js JSON-LD.

After launch: tell search engines about changes

Two cluster posts cover notification: IndexNow in Next.js for Bing, Yandex and the other IndexNow engines when content is published, updated or deleted, and the Google Indexing API for job postings if you run a job board. For everything else, Google relies on the sitemap and links from check 11 and check 13.

Run the checklist across the whole site

The curl commands above check one URL per template. A crawl checks every URL, and finds the templates you forgot to test. SEOFix crawls the server HTML of every page and groups the issues by template (fixing by template), so "200 with noindex on 4,000 product URLs" shows up as one problem in one route file. Most checks in this list map to an issue code in the issue reference: missing or duplicate titles, canonicals to redirects or to the home page, broken pages, redirect chains, meta-refresh redirects, noindex pages in the sitemap, robots.txt failures and invalid JSON-LD.

FAQ

Is Next.js good for SEO? The App Router renders on the server by default, has a typed Metadata API, and generates robots.txt and sitemaps from code. Those are the parts that matter. The risks are the streaming defaults in checks 4 and 5. A client-rendered React app doesn't have those, but it has bigger ones; see React SEO for single-page apps.

Should I use next-seo with the App Router? The built-in Metadata API covers titles, descriptions, canonicals, Open Graph, robots and hreflang (alternates.languages). JSON-LD is a script tag. We don't see a need for a package on new App Router projects.

Do Pages Router sites need the same checks? Most of them, yes: titles, canonicals, status codes, redirects and robots rules matter the same way. The code differs (next/head, and getServerSideProps returning notFound: true for a 404), and the streaming checks in 4 and 5 are App Router behaviour.


Run all 14 checks on every page at once: the first SEOFix audit is free (up to 500 pages).

ShareXLinkedIn