Next.js SEO: the complete App Router setup, file by file
Metadata API, robots.ts, sitemap.ts, JSON-LD, canonical URLs and self-hosted fonts: the six file conventions that carry Next.js App Router SEO, from a site that runs all of them in production.
By Nabeel Sharafat · 2026-07-31 · 4 min read · 874 words
Next.js is the framework this playbook is written against first, and the reason is boring: the App Router exposes more SEO surface as typed file conventions than anything else. Metadata is an export. robots.txt is a file. The sitemap is a function returning objects. Nothing needs a plugin.
That is also the trap. The conventions carry the mechanics, and people mistake them for carrying the decisions. They do not. Here is the whole setup, file by file, with the decision each file actually requires. Every pattern below runs in production on this site, and the Next.js App Router stack page has the six copyable recipes with the full code.
1. Metadata: the title template and the canonical
The root layout sets a title template and metadataBase:
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
default: "SaaS SEO playbook for solo founders: 112 checks, working code",
template: `%s | SEO for Solos`,
},
};Two decisions hide in those lines. First, the template puts the brand last, because the first 60 characters of a title carry nearly all its weight and the brand is the one word nobody searched for. Second, metadataBase makes every relative image and canonical URL resolve against one origin, which is what stops a preview deployment leaking its hostname into production metadata.
Every page then declares its own canonical through alternates.canonical, absolute and self-referencing. A page reachable at several URLs without one competes with itself.
2. robots.ts: generated, not maintained
The file convention means robots.txt is code, so it can be built from your route constants instead of drifting from them. The decision it requires is per crawler: which bots return a visit or a citation for the bandwidth they consume, and which only take. The split and the numbers are in the robots.txt decision post; the short version is that the named-agent inheritance trap catches everyone once, because a named user agent inherits nothing from the wildcard block.
The robots.txt generator outputs both the text file and the equivalent app/robots.ts.
3. sitemap.ts: the lastModified accessor is the whole game
app/sitemap.ts returns objects and Next serialises them. The convention makes one specific mistake easy: lastModified: new Date() type-checks, ships, and stamps every URL with the build time. Google reads lastmod only while it stays consistently accurate, so a build stamp teaches the crawler to ignore the field, and that trust does not come back.
The fix is structural rather than disciplinary: compose the sitemap from content modules, and require a lastModified accessor per route group, so the date has to come from the content. The sitemap planner generates that composer from a description of your page tiers.
4. JSON-LD: one script tag, one graph
The Metadata API does not cover structured data, and the other field mangles it. The supported pattern is a script tag rendered in the page component:
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(graph) }}
/>The decision is what goes in it: one @graph per page with stable @id anchors, so the Organization node declared on the home page can be referenced by every Article without restating it. Which types are worth the effort is its own accounting, and we published ours: four of 42 produced a visible search result change.
5. Static by default, and how routes silently stop being static
Static generation is the App Router default, and it is the correct default for SEO: a marketing page that renders without touching a database cannot be taken down by the database. But the promise is fragile in one specific way. Reading cookies, headers or request-time search params anywhere in a route's module tree makes the whole route dynamic, with no warning beyond a changed symbol in the next build route table.
Two practical consequences:
- Read the build output. The route table is the only place the regression is visible.
- A nonce-based Content-Security-Policy requires reading headers in the root layout, which makes every route dynamic. That is why this site runs a static-first CSP with
'unsafe-inline'and documents the tradeoff instead of hiding it.
generateStaticParams has its own edge: it prerenders the params you list, but unknown params still render on demand unless dynamicParams is false or the page gates against an authoritative list and calls notFound(). Without the gate, /stack/anything-at-all renders an empty shell and gets indexed as thin content.
6. next/font: the external request that never ships
next/font downloads the font at build time and self-hosts it, so the browser never contacts a font CDN. The decision here is only restraint: adding a Google Fonts <link> back reintroduces the request the build removed, and it usually comes in through a copied component.
What the framework does not solve
The list above is mechanics. Next.js has no opinion on whether your titles lead with the query, whether your programmatic tier survives the entity-name test, or whether your content answers anything. The framework removes the plumbing excuses, which is worth a lot, and it moves none of the judgement.
The free checklist covers all 112 checks with a verification command for each, and the stack page has the six Next.js recipes, including the three things the App Router makes harder than average.
Questions
Does Next.js handle SEO automatically?
Next.js provides the mechanisms for SEO, not the decisions. The App Router ships typed conventions for metadata, robots.txt, sitemaps and Open Graph images, but a build-timestamped sitemap and a missing canonical are both valid uses of those conventions. The framework removes the plumbing excuses, nothing more.
How do I add JSON-LD structured data in the Next.js App Router?
JSON-LD in the Next.js App Router ships as a script tag of type application/ld+json rendered inside the page component, holding one @graph with stable @id anchors. The Metadata API does not cover structured data, so the script tag is the supported pattern.
Why is my Next.js page not statically generated?
A Next.js App Router page becomes dynamic when anything in its module tree reads cookies, headers or search params at request time. The route then renders on demand without warning. Checking the route table printed by next build is the fastest way to catch it.