Skip to content
brainNotFound

Engineering

SEO in the Next.js App Router: A Field Guide

Metadata, canonicals, structured data and static generation — how the App Router actually wants you to do technical SEO.

VoidReturn3 min read
Concentric signal rings over a dark grid, labelled 200 OK

The App Router quietly fixed most of what used to make technical SEO in React painful. Server components render real HTML, the Metadata API removes the need for a <Head> juggling act, and generateStaticParams turns dynamic routes into a wall of pre-rendered pages. What is left is a set of decisions that are easy to get subtly wrong.

This is the checklist I run on every content site I build, in the order the crawler experiences it.

1. Render on the server, always

The single highest-leverage rule: no content should require client JavaScript to appear in the HTML source. Server components are the default in the App Router, so this mostly means not reaching for "use client" out of habit. When you do need interactivity, isolate it.

A useful test is to disable JavaScript and read the page. If the article body, the navigation and the internal links are all still there, crawlers will see them too. If a category filter turns into an empty list, you have built a page Google cannot use.

2. One metadata factory, not fifty copies

generateMetadata is per-route, which invites copy-paste. Centralise the fallback logic instead: a single builder that takes a title, a description, a path and an image, and resolves everything against site defaults.

lib/seo/metadata.tstypescript
export function buildMetadata({
  title,
  description,
  path,
  image,
  noIndex,
}: BuildMetadataInput): Metadata {
  const canonical = absoluteUrl(path);

  return {
    title,
    description,
    alternates: { canonical },
    robots: noIndex
      ? { index: false, follow: false }
      : { index: true, follow: true },
    openGraph: {
      type: "article",
      url: canonical,
      title,
      description,
      images: image ? [image] : undefined,
    },
    twitter: { card: "summary_large_image" },
  };
}

Two properties matter more than the rest. alternates.canonical must be absolute — set metadataBase in the root layout and Next will resolve relative paths for you. And robots should be explicit rather than inherited, so a noIndex flag in the CMS has somewhere to land.

3. Static generation with on-demand revalidation

For a content site, the right default is static pages plus a webhook. generateStaticParams pre-renders every known slug at build time; a webhook from the CMS calls revalidateTag when something is published, so the page updates in seconds without a redeploy.

app/api/revalidate/route.tstypescript
const { _type, slug } = await request.json();

revalidateTag("sanity");
revalidateTag(_type);
if (slug) revalidateTag(`${_type}:${slug}`);

Tag granularity is the whole game. Tag too broadly and every publish rebuilds the world; tag too narrowly and the blog index keeps showing yesterday's post. Type-level plus document-level tags is the sweet spot.

4. Structured data belongs in the server HTML

JSON-LD injected by a client effect is a coin flip. Render it as a <script type="application/ld+json"> inside your server component and it is simply part of the document.

Which types you need depends on the template. For a blog, that is BlogPosting on articles, CollectionPage plus ItemList on archives, and BreadcrumbList everywhere — matching the breadcrumbs a human can actually see. I go into the content modelling side of this in Portable Text That Ranks.

TemplatePrimary typeAlways also
ArticleBlogPostingBreadcrumbList
Category archiveCollectionPage + ItemListBreadcrumbList
Case studyCreativeWork + ReviewBreadcrumbList
ServicesService + FAQPageBreadcrumbList
What to emit on each template

5. Pagination that does not leak

Paginated archives are where sites quietly bleed crawl budget. The rules that have held up: each page is canonical to itself, never to page one; page two and beyond stay indexable unless they are genuinely thin; and any URL carrying a filter or search parameter gets noindex.

Layered rectangles over a dark grid representing paginated archive pages
Each archive page is its own crawlable URL — not a client-side view of one.

6. Measure the thing you promised

Set budgets before you build, not after the client complains. Mine are LCP under 2.5s, CLS under 0.1, INP under 200ms, and a Lighthouse SEO score of exactly 100 — anything less means something structural is wrong, not something cosmetic.

The budget only works if it is enforced somewhere a human will see it. I cover how I hold that line in A Core Web Vitals Budget You Can Actually Ship.

The short version

  1. Server-render everything; isolate interactivity.
  2. One metadata factory with absolute canonicals.
  3. Static params plus tagged on-demand revalidation.
  4. JSON-LD in the server HTML, matching visible content.
  5. Self-canonical pagination; noindex parameterised URLs.
  6. Budgets you enforce, not aspirations you quote.

// related

Keep reading.

All Engineering