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.
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 type | Mode | Why |
|---|---|---|
| Marketing, service, industry pages | Static | Content changes on a human schedule; serve it from the edge with no cold path |
| Case studies, handbook articles | Static with generateStaticParams | Known set, known slugs — pre-render all of them and skip runtime rendering entirely |
| Listing pages with filters | Static shell, client-side filtering | One indexable page per meaningful facet; do not let a filter state create a URL |
| Search results, account pages | Dynamic, and noindex | Infinite URL space with no unique value — the crawler should never see it |
next build
# ○ (Static) prerendered as static content
# ● (SSG) prerendered using generateStaticParams
# ƒ (Dynamic) server-rendered on demand <-- justify every one of theseMetadata 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.
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.
| Type | Where | What it actually does |
|---|---|---|
| Organization | Root layout, once | Ties the name, logo, address and profiles into one entity — the basis of a knowledge panel and of an answer engine identifying you correctly |
| Article / TechArticle | Handbook and case studies | Establishes author, publication and update dates; the update date is what stops a two-year-old page being treated as stale |
| FAQPage | Pages with genuine question–answer pairs | Rich results have narrowed, but the markup remains one of the cleanest signals for answer extraction |
| BreadcrumbList | Every nested page | Replaces 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.
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];
}