Most technical SEO advice for Next.js is either generic — write good titles, earn links — or quietly written for the Pages Router. The App Router replaced nearly every piece of hand-rolled SEO plumbing with typed file conventions: metadata is an exported object, the sitemap is a function, the OG image is a file sitting next to the page it describes. Once you know the conventions, a properly crawlable site is a handful of small files, not a plugin stack.
What follows is the actual checklist behind this site — a small content site and personal link shortener running on Next.js 16 with the App Router. Every code sample below is lightly adapted from the running implementation, so none of it is theoretical: you can view source on this very page and find each tag it produces.
The order is deliberate. Rendering comes first, because nothing else matters if crawlers receive an empty shell. Then metadata, sitemaps and robots rules, OG images, structured data, feeds, internal links, and finally the mistakes that show up in almost every App Router codebase.
Static rendering first: ship complete HTML
Google can execute JavaScript. Its own JavaScript SEO documentation explains that Googlebot renders pages with an evergreen version of Chromium — but rendering is a second phase. Pages are queued for rendering after the initial crawl, and Google says a page may sit in that queue for a few seconds or longer. And Googlebot is the best-case crawler. Open Graph scrapers, feed readers, most non-Google search bots, and a growing crowd of AI crawlers read the raw HTML response and never run a script. Content that only exists after hydration does not exist for them.
The App Router default works in your favor here. Server components prerender to plain HTML at build time as long as the route avoids request-time APIs like cookies(), headers(), or uncached fetches. Every post on this site is a static server component: the article is in the JSX tree, the build emits finished HTML, and a crawler that ignores JavaScript entirely still sees the whole thing. There is no client-side data fetching involved in rendering content, and nothing for a bot to wait on.
The practical rules: keep content pages fully static; reserve dynamic rendering for routes that genuinely need request data, like a dashboard or an auth callback; and never let a stray cookies() call in a shared layout drag your entire content tree into request-time rendering. The output of next build tells you route by route whether you got this right — content routes should be listed as prerendered.
The Metadata API: metadataBase, title templates, canonicals
The Metadata API turns head-tag management into a typed object export. The root layout on this site sets three things every page inherits:
// app/layout.tsx
export const metadata: Metadata = {
metadataBase: new URL(env.SITE_URL),
title: {
default: "A small website",
template: "%s",
},
description: "Practical writing on AI, web analytics, ...",
alternates: {
types: { "application/rss+xml": "/rss.xml" },
},
openGraph: { type: "website", url: "/" },
};metadataBase is the one field people skip and regret. Every URL-based metadata field — canonical, og:url, og:image — resolves relative paths against it, which is what lets the rest of the codebase use clean paths like /blog/slug. Skip it and current Next.js fails the build when it encounters a relative URL field. The title.template lets child pages set a bare title while the layout controls the pattern (a template requires a default). And the alternates.types entry emits the RSS autodiscovery link that feed readers look for.
Per-page metadata on this site comes from a single helper that reads a content registry — more on the registry below — so every post gets a canonical URL and article-typed Open Graph tags from one code path:
// lib/posts.ts
export function postMetadata(slug: string): Metadata {
const post = getPost(slug);
if (!post) throw new Error("Unknown post slug: " + slug);
return {
title: post.title,
description: post.description,
alternates: { canonical: "/blog/" + post.slug },
openGraph: {
title: post.title,
description: post.description,
type: "article",
url: "/blog/" + post.slug,
publishedTime: post.date,
tags: post.tags,
},
};
}The canonical tag is not decorative, even on a site where each page has exactly one URL. The moment a link circulates with campaign parameters attached, every parameterized variant is a duplicate URL, and the canonical tells search engines which one accumulates the ranking signals. Redirects are the other consolidation lever — when to use each status code is its own topic, covered in 301 vs 302 redirects. Two gotchas worth knowing: metadata objects merge shallowly across layout and page, so a page that sets openGraph at all replaces the entire inherited openGraph object; and if you compute metadata in an async generateMetadata that turns dynamic, Next.js streams the tags into the body for JavaScript-capable bots and only renders blocking head tags for HTML-limited bots. Static metadata sidesteps all of that.
sitemap.ts and robots.ts: two file conventions, zero plugins
Everything on this site renders from one registry: a typed array of posts with slug, title, description, date, and tags. The blog index, the per-post metadata, the sitemap, the RSS feed, and the related-post links all read from that single list, which means a post cannot appear in the sitemap without a real page existing, and a page cannot ship without joining the sitemap. Whatever your content source is — a registry file, MDX frontmatter, a CMS — the principle is the same: generate every SEO surface from it, never maintain them by hand.
The sitemap file convention makes this a ten-minute job. A default export returning MetadataRoute.Sitemap is served at /sitemap.xml:
// app/sitemap.ts
export default function sitemap(): MetadataRoute.Sitemap {
const base = env.SITE_URL;
return [
{ url: base, changeFrequency: "monthly", priority: 1 },
{ url: base + "/blog", changeFrequency: "weekly", priority: 0.9 },
...POSTS.map((post) => ({
url: base + "/blog/" + post.slug,
lastModified: post.date,
changeFrequency: "monthly" as const,
priority: 0.8,
})),
];
}The protocol caps a single sitemap at 50,000 URLs; Next.js has a generateSitemaps function for splitting past that, which a blog will never need. robots.ts works the same way:
// app/robots.ts
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/dashboard", "/api/", "/auth/", "/login"],
},
],
sitemap: env.SITE_URL + "/sitemap.xml",
};
}Keep crawlers out of application chrome — dashboards, API routes, auth flows — and point them at the sitemap. Two cautions: robots.txt controls crawling, not indexing, so it will not remove an already-indexed page; and never disallow your static asset paths, because Google will not render JavaScript from files its crawler is blocked from fetching. Blocking CSS and JS is one of the few ways to make rendering-based indexing strictly worse.
Open Graph images with opengraph-image.tsx
Drop an opengraph-image.tsx file into any route directory and Next.js generates the og:image tags for that route — og:image itself plus its type, width, height, and alt attributes — with absolute URLs resolved against metadataBase. The og:image family is all this convention emits; twitter:image comes from a separate twitter-image file convention, though most platforms (including X) fall back to og:image when twitter:image is absent. The image itself is rendered by ImageResponse from next/og, which turns a constrained subset of JSX — flexbox and inline styles — into a PNG at the standard 1200 by 630 size the Open Graph protocol ecosystem expects. No design tool, no image assets checked into the repo.
This site keeps one shared renderer, so each post directory only carries a few declarative lines:
// lib/og.tsx
import { ImageResponse } from "next/og";
export const OG_SIZE = { width: 1200, height: 630 };
export function ogImage(title: string) {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
padding: 80,
background: "#0a0a0a",
color: "#ededed",
fontSize: 62,
fontWeight: 600,
}}
>
{title}
</div>
),
OG_SIZE,
);
}
// app/blog/my-post/opengraph-image.tsx
export const size = OG_SIZE;
export const contentType = "image/png";
export const alt = "My post title";
export default function Image() {
return ogImage("My post title");
}One detail from the docs worth remembering: file-based metadata takes priority over the metadata object, so once the file exists, do not also set openGraph.images by hand — the convention wins, and duplicating it just creates a second place to forget.
Structured data: Article JSON-LD is just a script tag
There is no special App Router API for structured data, and none is needed: a server component can render a script tag whose body is a serialized object. Google documents Article markup with headline, datePublished, and dateModified as the core recommended properties, in JSON-LD form. The shared post layout on this site builds it from the same registry entry that produced the visible header:
// app/blog/post-layout.tsx (inside the layout component)
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
description: post.description,
datePublished: post.date,
dateModified: post.date,
url,
mainEntityOfPage: url,
keywords: post.tags.join(", "),
};
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>;Deriving the markup from the same data that renders the page is the whole trick: the structured data cannot drift from the visible content, which is exactly what Google asks for. Claim only what the page actually shows — inflated or invisible markup is the fastest route to a manual action, and it earns nothing.
RSS and internal linking
RSS as a route handler
There is no file convention for RSS, but a route handler at app/rss.xml/route.ts gets you a feed at a clean URL. On this site it is a static GET that maps the registry to item elements and returns the XML with the right content type:
// app/rss.xml/route.ts
export function GET() {
const base = env.SITE_URL;
const items = POSTS.map((post) => {
const url = base + "/blog/" + post.slug;
return (
"<item><title>" + escapeXml(post.title) + "</title>" +
"<link>" + url + "</link>" +
'<guid isPermaLink="true">' + url + "</guid></item>"
);
}).join("");
return new Response(wrapChannel(items), {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
},
});
}A feed is not a ranking factor. It is distribution: readers, aggregators, and plenty of crawlers still poll feeds, it gives subscribers a spam-free channel, and it costs about thirty lines. The alternates.types entry in the root layout advertises it on every page.
Internal links: no orphan pages
Crawlers discover and weigh pages through links, so a site where every post is reachable within two clicks of the homepage indexes better than one with orphans. This site gets that structurally: the shared post layout computes related posts by tag overlap and appends up to three sibling links to every article, and the blog index links every post. On top of the structural links, in-prose links with descriptive anchors carry the most information — the way a post here about whether URL shorteners hurt SEO links directly into the redirect-status discussion rather than saying “click here”.
Common App Router SEO mistakes
- Content behind a client component. Marking a page with the
use clientdirective and fetching the content in an effect means the served HTML contains a shell and no article. JavaScript-free crawlers index nothing; even Google indexes it late, via the render queue. - Forgetting metadataBase. Current Next.js fails the build on relative URL metadata fields without it; older versions shipped pages with broken or localhost OG URLs. If a social debugger shows no preview image, check this first.
- No canonical tag. Every campaign-tagged or otherwise parameterized variant of a URL competes with the clean one until a canonical consolidates them.
- Blocking CSS and JS in robots.txt. Google renders pages with a real browser; it will not render resources it cannot fetch, and an unrenderable page indexes worse.
- Accidental dynamic rendering. One request-time API call in a shared layout opts descendant routes out of static generation. Audit the
next buildroute summary, not your intentions. - Hand-maintained sitemaps. Any list of URLs curated separately from the pages themselves will drift. Generate the sitemap from the same source of truth that generates the routes.
The checklist
- Content pages statically rendered; verify in the build output.
- Root layout:
metadataBase, title template, site description, RSS alternate. - Every page: unique title, description, and canonical from one shared code path.
sitemap.tsandrobots.tsgenerated from the content registry.opengraph-image.tsxper post via a sharedImageResponsehelper.- Article JSON-LD derived from the same registry entry.
- RSS route handler, advertised via the metadata alternates.
- Related-post links in the shared layout; no orphan pages.
None of this is clever, and that is the point. Technical SEO for an App Router site is mostly about letting the framework do what it already does well — static HTML, typed metadata, file conventions — and wiring every surface to one source of truth so nothing drifts. Ship the checklist, submit the sitemap in Search Console, and spend the reclaimed time on the part that actually ranks: the writing.