SEO

Technical SEO for Next.js App Router Sites: A Checklist That Survives Deploys

A practical checklist for Next.js App Router sites: metadata and canonicals, generated sitemaps, prerender verification, structured data, and the leaks that quietly break social previews.

5 min read

A framework gives you the primitives for crawlable pages; it does not guarantee that any particular page shipped with a correct title, a valid canonical, or an image that actually resolves. Most technical SEO problems found in reviews are not subtle — they are one missing file, one wrong default, or content that renders only after hydration.

This checklist is written for the Next.js App Router, and the commands assume a production build in .next. It describes a verification workflow, not results from a specific site.

Metadata belongs in one place per route

Set metadataBase once in the root layout, then let child routes declare only what differs. metadataBase is what turns a relative path like /og.png into an absolute URL — without it, Open Graph images and canonical links can be emitted relative, and consumers may ignore them.

// app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL("https://example.com"),
  title: {
    default: "Example — Systems That Scale",
    template: "%s — Example",
  },
  alternates: { canonical: "/" },
  robots: { index: true, follow: true },
};

Two habits prevent most canonical mistakes: give every route its own alternates.canonical, and make the blog index and each article canonical to themselves rather than to the home page. A canonical pointing at the wrong URL removes the page from the index without any warning in the UI.

Verify the built HTML, not the dev server

The only meaningful check is the artifact you deploy. Next writes prerendered pages to disk, so you can inspect them directly:

# Pre-rendered route output for the App Router
ls .next/server/app/blog

# Does the article text exist without JavaScript?
grep -c "session caching" .next/server/app/blog/scaling-auth.html

# Title, canonical, and structured data
grep -o '<title>[^<]*</title>'         .next/server/app/blog/scaling-auth.html
grep -o 'rel="canonical" href="[^"]*"' .next/server/app/blog/scaling-auth.html
grep -o '"@type":"BlogPosting"'        .next/server/app/blog/scaling-auth.html

If the text count is zero, the page is client-rendered and you are relying entirely on the crawler's JavaScript execution. That may still work, but you have removed your ability to verify it cheaply.

Also check the deployed URL, not only the build directory — a route can prerender correctly and still be served behind a redirect:

curl -s -o /dev/null -w "%{http_code} %{content_type}\n" \
  https://example.com/blog/example-post/opengraph-image

Prefer generated images, but verify the static ones

A dynamic opengraph-image.tsx route gives every article a correct card without design work, and Next injects the tags for you. The failure mode is the fallback: a layout that references /og.png for every other route will emit a broken image if that file was never added to public/. A 404 image does not break the page, so nothing surfaces it — only a curl or a social debugger will.

Generate the sitemap and robots from the same data as the pages

Route handlers keep the sitemap in sync with the content directory instead of a hand-maintained list:

// app/sitemap.ts
const base = siteConfig.url;

export default function sitemap(): MetadataRoute.Sitemap {
  return [
    { url: `${base}/blog`, changeFrequency: "weekly", priority: 0.9 },
    ...getAllBlogPosts().map((post) => ({
      url: `${base}/blog/${post.slug}`,
      // Use the real modification date; do not stamp every deploy.
      lastModified: new Date(post.updated ?? post.date),
      changeFrequency: "monthly" as const,
      priority: 0.7,
    })),
  ];
}

Two details that are easy to get wrong: lastModified should reflect content changes rather than build time, and the sitemap must only contain indexable URLs. Adding a noindex page to the sitemap sends contradictory signals.

Make structured data match what a reader sees

BlogPosting and BreadcrumbList markup is cheap to add and easy to desynchronize. Rules that keep it honest:

  • headline, datePublished, and dateModified must match the visible page.
  • The author entity referenced by @id should resolve to something real, not an anchor that does not exist.
  • If you declare breadcrumbs in JSON-LD, show breadcrumbs in the interface. Markup describing an invisible hierarchy is a mismatch, not a bonus.

Validate with the Rich Results Test after a deploy, not against a local HTML file.

The leaks that cost the most

  • Content visible only after hydration. Animation wrappers that start at opacity: 0 ship in the HTML as hidden. Text is usually still readable by crawlers, but a CSS reveal avoids the question entirely for the elements that carry the content.
  • Invalid attributes from spreading library props. Renderers that pass library internals into the DOM produce markup like <h2 node="[object Object]"> on every heading. Destructure the internal props before spreading.
  • Missing verification without losing it. A DNS TXT record keeps a Domain property verified across deploys; a meta tag must stay in the layout permanently. Whichever you choose, do not remove it after verification succeeds.
  • Unverified indexing. Crawlability is not indexing. Confirm the important URLs with URL Inspection in Search Console, and submit the sitemap once rather than re-submitting after every deploy.

A deploy checklist

  1. next build prints the expected routes, with content pages prerendered rather than dynamic.
  2. One article's HTML contains its body text, title, canonical, and JSON-LD.
  3. The Open Graph image URL returns 200 with an image content type on the live domain.
  4. robots.txt allows crawling and references the sitemap.
  5. The sitemap lists every published article and no non-indexable route.
  6. A previously indexed URL still returns 200 and still declares the same canonical.
  7. Rich Results Test reports no errors on one article and on the blog index.

For a worked example of the content side of this pipeline, see design tokens at scale, and browse the current systems for the projects these notes support.