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

> A technical Next.js SEO checklist for the App Router: server HTML, metadata, canonicals, status codes under streaming, redirects, robots.ts, sitemaps and JSON-LD.

Source: https://seofix.ai/blog/nextjs-seo-checklist  
Published: 2026-11-15  
Updated: 2026-11-02  
Author: SEOFix Team  
Category: Technical SEO  
Tags: Technical SEO, Guides  
Length: 1,690 words, 8 min read

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?](https://seofix.ai/blog/do-ai-crawlers-render-javascript)).

```bash
# 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](https://seofix.ai/help/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:

```typescript
// 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.',
}
```

```typescript
// 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](https://nextjs.org/docs/app/api-reference/functions/generate-metadata) 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.

```bash
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](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls) 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](https://nextjs.org/docs/app/api-reference/functions/generate-metadata)). 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>`."

```bash
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](https://nextjs.org/docs/app/api-reference/functions/not-found) confirm the mechanism: once streaming has started, the response "keeps its `200` status."

```bash
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](https://developers.google.com/crawling/docs/troubleshooting/http-status-codes) that return `4xx`, while `5xx` only slows its crawling. Distinguish "not found" from "couldn't check":

```typescript
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 `308`s in a row).

```bash
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](https://nextjs.org/docs/app/api-reference/config/next-config-js/trailingSlash)). 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.

```bash
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](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/robots)). 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.

```bash
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](https://nextjs.org/docs/app/api-reference/file-conventions/proxy)). 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:

```typescript
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon\\.ico|robots\\.txt|sitemap|llms\\.txt).*)'],
}
```

```bash
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.

```bash
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):

```typescript
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](https://nextjs.org/docs/app/api-reference/config/next-config-js/headers)):

```typescript
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](https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag) 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:

```bash
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.

## 13. Links are real links, images have alt text

Google "can only crawl your link if it's an `<a>` HTML element with an `href` attribute" ([Google](https://developers.google.com/search/docs/crawling-indexing/links-crawlable)). `next/link` "extends the HTML `<a>` element" ([Link docs](https://nextjs.org/docs/app/api-reference/components/link)), 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](https://nextjs.org/docs/app/api-reference/components/image)). TypeScript enforces that it exists, not that it's useful: `alt=""` is right for decorative images and wrong for product photos.

```bash
# 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](https://seofix.ai/blog/indexnow-nextjs) for Bing, Yandex and the other IndexNow engines when content is published, updated or deleted, and the [Google Indexing API for job postings](https://seofix.ai/blog/google-indexing-api-job-postings-nextjs) 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](https://seofix.ai/help/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](https://seofix.ai/help/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).
