Skip to content
HandbookPublic
Search

Technical SEO for the Next.js App Router

The rendering, canonical and metadata decisions that decide whether a modern React site is indexed properly — and the four mistakes we find on almost every audit.

Written forEngineers responsible for a React or Next.js site that has to rank
Reading time12 min read
Last reviewed2026-08-21

Search engines render JavaScript, so the old advice — "React sites cannot rank" — is wrong. What is true is subtler and more expensive: rendering is queued separately from crawling, so anything that only exists after hydration is indexed late, sometimes by weeks, and anything that depends on a click is not indexed at all. Technical SEO on the App Router is mostly the discipline of deciding, per route, what exists in the first HTML response.

Choose the rendering mode per route, deliberately

The App Router will happily make every page dynamic because one component reached for a header or a cookie. That decision is usually invisible in development and very visible in your crawl stats. Audit it as a build output, not as an intention.

Route typeModeWhy
Marketing, service, industry pagesStaticContent changes on a human schedule; serve it from the edge with no cold path
Case studies, handbook articlesStatic with generateStaticParamsKnown set, known slugs — pre-render all of them and skip runtime rendering entirely
Listing pages with filtersStatic shell, client-side filteringOne indexable page per meaningful facet; do not let a filter state create a URL
Search results, account pagesDynamic, and noindexInfinite URL space with no unique value — the crawler should never see it
bash
next build

# ○  (Static)   prerendered as static content
# ●  (SSG)      prerendered using generateStaticParams
# ƒ  (Dynamic)  server-rendered on demand   <-- justify every one of these
Read the build output as a report. Anything unexpectedly dynamic is a bug in the SEO surface.

Metadata that survives a template

Metadata generated from a template produces duplicate titles at scale — the classic symptom is a Search Console report showing hundreds of pages with the same title and a handful indexed. Two rules prevent it: every indexable route declares its own canonical, and every title carries something that only that page has.

ts
export async function generateMetadata({ params }): Promise<Metadata> {
  const { slug } = await params;
  const study = getCaseStudy(slug);
  if (!study) return {};

  return {
    title: `${study.client} — ${study.title} | AONE`,
    description: study.summary,             // written per page, never templated
    alternates: { canonical: `/work/${study.slug}` },
    openGraph: {
      type: 'article',
      url: `/work/${study.slug}`,
      images: [{ url: study.ogImage, width: 1200, height: 630 }],
    },
  };
}
  • Set metadataBase once in the root layout, or every relative canonical and OG image silently resolves against the wrong origin in preview deployments.
  • A canonical must be self-referential on the page it describes. Pointing several pages at one canonical to "consolidate authority" removes them from the index instead.
  • Return an empty object from generateMetadata for a route that will 404 — inheriting the parent title gives you an indexable-looking error page.

Structured data: the types that still earn something

Most schema.org markup on the web is decorative. A small set of types still changes what a result looks like or how confidently an engine can identify you, and the rest is noise that has to be maintained. We ship four.

TypeWhereWhat it actually does
OrganizationRoot layout, onceTies the name, logo, address and profiles into one entity — the basis of a knowledge panel and of an answer engine identifying you correctly
Article / TechArticleHandbook and case studiesEstablishes author, publication and update dates; the update date is what stops a two-year-old page being treated as stale
FAQPagePages with genuine question–answer pairsRich results have narrowed, but the markup remains one of the cleanest signals for answer extraction
BreadcrumbListEvery nested pageReplaces the URL in the result with a readable hierarchy, and states the site structure explicitly

Sitemaps as a signal, not a dump

A sitemap listing every URL with today's date is worse than no sitemap: it trains the crawler to ignore lastmod. Emit only canonical, indexable URLs, and give each an honest modification date derived from the content itself.

ts
export default function sitemap(): MetadataRoute.Sitemap {
  const studies = CASE_STUDIES.map((s) => ({
    url: `${BASE}/work/${s.slug}`,
    lastModified: new Date(s.updated),     // the content's own date
    changeFrequency: 'yearly' as const,
    priority: s.flagship ? 0.9 : 0.7,
  }));

  return [...staticPages, ...studies, ...docs];
}
app/sitemap.ts — dates come from the content, so they are true by construction.

The four findings we make on almost every audit

01
Content behind an interactionTabs, accordions and "read more" that mount content on click. If it is not in the HTML, treat it as unindexed. Render it and hide it with CSS instead — hidden text is fine, absent text is not.
02
Parameter-generated duplicatesSort, filter and tracking parameters producing thousands of near-identical URLs. Every one consumes crawl budget. Canonicalise to the clean URL and keep the facets that deserve indexing as real routes.
03
Redirect chains from a migrationOld URL → new URL → trailing-slash variant → https. Each hop loses a little and slows the crawl. Flatten every chain to a single 301 and test them as a suite, not by hand.
04
INP measured in the lab onlyLighthouse cannot measure Interaction to Next Paint properly, because it does not interact. Field data from real sessions is the only source that matches what Search Console reports, and it is usually worse than the lab number on pages heavy with third-party script.