Next.js SEO in the App Router is mostly a handful of files done right: a root metadata export, a generateMetadata per page, sitemap.ts, robots.ts, an opengraph-image and a JSON-LD script. Each has a mistake that quietly costs you: a canonical that points every page at your homepage, Open Graph tags that vanish on child pages, a sitemap where every date is “now”. This guide covers the full Next.js 16 setup, with the failure mode and fix for each piece, verified on a 16.4 build.
Root metadata: metadataBase and a title template
Start in the root layout. Keep the values you reuse in one module so pages can import them:
// lib/seo.ts
import type { Metadata } from "next";
export const SITE_URL = "https://www.example.com";
export const PERSON_ID = `${SITE_URL}/#person`;
export const WEBSITE_ID = `${SITE_URL}/#website`;
// Spread into every openGraph object: nested fields don't merge.
export const baseOpenGraph = {
siteName: "Jane Doe",
locale: "en_US",
} satisfies Metadata["openGraph"];// app/layout.tsx
import type { Metadata } from "next";
import { SITE_URL, baseOpenGraph } from "@/lib/seo";
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
default: "Jane Doe | Frontend Developer",
template: "%s | Jane Doe",
},
description: "Frontend developer writing about React and Next.js.",
openGraph: { ...baseOpenGraph, type: "website" },
// No alternates.canonical here: children would inherit it.
};
// export default function RootLayout(...) stays as it wasmetadataBase lets every URL field below it use relative paths: alternates.canonical: "/blog/x" renders as https://www.example.com/blog/x, and using a relative URL without it fails the build. The template applies only to child segments, which is why it needs a default for the root itself. A page that sets title: "About" renders About | Jane Doe, and title: { absolute: "..." } skips the template.
Notice what’s missing: alternates.canonical. With canonical: "/" in the root layout, a contact page that didn’t set its own rendered <link rel="canonical" href="https://www.example.com"/>. Every page without an override now claims to be your homepage. Set canonicals per page, including "/" in app/page.tsx.
Per-page generateMetadata and the shallow-merge trap
Metadata merges from the root down, but shallowly. A page that sets openGraph replaces the parent’s whole openGraph object. In my test build, a post page that set only type and url lost og:site_name and og:locale. The opposite fails too: if the root’s openGraph sets a title, pages without their own openGraph inherit it. Leave it out; Next.js fills a missing og:title from the page’s title. The fix is to always set openGraph on content pages and spread the shared base into it:
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { getPost } from "@/lib/posts";
import { baseOpenGraph } from "@/lib/seo";
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug); // "use cache", shared with the page
if (!post) return { title: "Post not found", robots: { index: false } };
return {
title: post.title,
description: post.brief,
alternates: { canonical: `/blog/${slug}` },
openGraph: {
...baseOpenGraph,
type: "article",
url: `/blog/${slug}`,
title: post.title,
description: post.brief,
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
},
};
}Two Next 16 details matter here. With Cache Components on, generateMetadata follows the same rules as components, so uncached data on an otherwise prerenderable page raises an error; read through a "use cache" function, which the page then reuses. And pair the route with generateStaticParams so posts prerender. Dynamically rendered pages can stream metadata into the <body> after the content (Googlebot copes; HTML-limited bots like facebookexternalhit still get a blocking <head>), while prerendered pages ship everything in the <head>.
Metadata merges shallowly: whatever a page sets, it owns completely.
Canonical URLs: why query-string variants should consolidate
Every indexable page should declare a self-referencing canonical. The reason is query strings: /blog?utm_source=x, /blog?ref=newsletter and /blog?category=nextjs are different URLs to a crawler. When they render the same content, as a client-side category filter over the same list does, point them all at the clean URL so links and signals consolidate on one page. Google calls rel="canonical" a strong signal (not a guarantee), asks for absolute URLs, which metadataBase produces for you, and says not to use robots.txt for canonicalization.
Two exceptions. Paginated pages show different content, so /blog?page=2 gets its own canonical; Google explicitly says not to canonicalize page 2 to page 1. And for sprawling filter and sort combinations, Google’s e-commerce guidance suggests keeping unwanted variants out of the index with noindex, or out of the crawl with robots.txt.
sitemap.ts with real lastModified dates
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { getPosts } from "@/lib/posts";
import { SITE_URL } from "@/lib/seo";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// No try/catch: a failure must not be cached as an empty sitemap.
const posts = await getPosts();
return [
// Bump by hand when the page's copy actually changes.
{ url: `${SITE_URL}/`, lastModified: "2026-09-05" },
...posts.map((post) => ({
url: `${SITE_URL}/blog/${post.slug}`,
lastModified: post.updatedAt, // the real edit date
})),
];
}The official examples use lastModified: new Date(), and copied into production it’s the worst possible value. Google says it uses <lastmod> only when it is consistently and verifiably accurate, so a sitemap where every page changed “just now” on every build teaches Google to ignore your dates. Use the stored edit timestamp for database content and a hand-maintained date for static pages. Skip changeFrequency and priority entirely: Google ignores both.
URLs must be absolute, and one sitemap tops out at 50,000 URLs or 50 MB. Beyond that, nest another sitemap.ts in a segment (app/blog/sitemap.ts serves /blog/sitemap.xml) and list it in robots.ts too, or split with generateSitemaps. Under Cache Components the sitemap reads cached data like any page, so let a failed query throw rather than caching an empty list.
robots.ts and noindex: crawling is not indexing
// app/robots.ts
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/lib/seo";
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: "*", allow: "/", disallow: "/api/" },
sitemap: `${SITE_URL}/sitemap.xml`,
};
}A static app/robots.txt works just as well. What matters is the difference between the two controls. robots.txt decides what may be crawled; noindex decides what may be indexed. They work against each other: for noindex to work, Google must be able to crawl the page, and a disallowed URL can still be indexed without its content if something links to it. So for pages that must stay out of results, allow crawling and send noindex.
Admin and account pages: export
robots: { index: false, follow: false }fromapp/admin/layout.tsx. Next.js renders<meta name="robots" content="noindex, nofollow"/>on every page below it.Non-HTML responses such as API JSON or PDFs: send an
X-Robots-Tag: noindexheader fromheaders()innext.config.ts. Google treats it the same as the meta tag.Preview deployments: Vercel already adds
X-Robots-Tag: noindexto them, except when a custom domain is attached to a non-production branch. Gate that case onprocess.env.VERCEL_ENV === "preview".Missing posts: call
notFound(), then check the status code. WithpartialPrefetching: true, my 16.4 build answered the first request for an unknown slug with200(the App Shell plus the not-found UI and anoindextag) and only later requests with404. With it off, the first response was already a404. That is the trade for instant shells on unlisted paths.
Dynamic OG images with ImageResponse
Drop an opengraph-image.tsx next to a page and Next.js generates the image and the tags: og:image with type, width, height and alt, plus twitter:image and a summary_large_image card if you haven’t set Twitter metadata.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { getPost } from "@/lib/posts";
export const alt = "Article on Jane Doe’s blog";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
// Module scope: the font never depends on the request.
const bold = await readFile(join(process.cwd(), "assets/Inter-Bold.ttf"));
type Props = { params: Promise<{ slug: string }> };
export default async function Image({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug).catch(() => null);
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex", // required on any element with 2+ children
flexDirection: "column",
justifyContent: "center",
padding: 80,
background: "#0a0a0a",
color: "#fff",
fontFamily: "Inter",
}}
>
<div style={{ fontSize: 28, opacity: 0.6 }}>example.com/blog</div>
<div style={{ fontSize: 72, fontWeight: 700 }}>
{post?.title ?? "From the blog"}
</div>
</div>
),
{ ...size, fonts: [{ name: "Inter", data: bold, weight: 700 }] },
);
}ImageResponse renders with Satori, which supports flexbox and a subset of CSS, no grid. The rule that bites: any element with more than one child needs an explicit display. Forget it on a statically rendered image and the build fails with “Expected <div> to have explicit "display: flex"…”. Fonts must be ttf, otf or woff, and the whole bundle is capped at 500 KB. Under the dynamic [slug] segment my build rendered this route on request, so keep it cheap: cached data and fonts read at module scope.
A useful precedence detail: when the page’s generateMetadata also returns openGraph.images, that URL won over the colocated file in my 16.4 build. That gives you “use the cover image if the post has one, otherwise the generated card”, as long as you leave the images key out when there is no cover: even images: undefined counts as set and hides the generated card.
JSON-LD structured data: one connected @id graph
Next.js recommends a plain <script type="application/ld+json"> in the layout or page, not next/script, which is meant for executable code. Its JSON-LD guide also points out that JSON.stringify does not sanitize strings, so a title containing </script> could end the tag and inject markup. Escape < as \u003c:
// components/json-ld.tsx
// Escaping "<" stops a "</script>" inside any string from ending the tag.
export function JsonLd({ data }: { data: object }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(data).replace(/</g, "\\u003c"),
}}
/>
);
}I fed it a post titled </script><script>alert(1)</script>. The output contained no raw </script, and JSON.parse returned the original title intact. Now build the graph. The root layout emits the site-wide nodes once, a Person and a WebSite with stable @id URLs. Each post then emits only what is specific to it and points back by @id:
// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { JsonLd } from "@/components/json-ld";
import { getPost } from "@/lib/posts";
import { PERSON_ID, SITE_URL, WEBSITE_ID } from "@/lib/seo";
type Props = { params: Promise<{ slug: string }> };
export default async function Page({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
const url = `${SITE_URL}/blog/${slug}`;
const crumbs = [
{ name: "Home", item: `${SITE_URL}/` },
{ name: "Blog", item: `${SITE_URL}/blog` },
{ name: post.title, item: url },
];
const graph = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "BlogPosting",
"@id": `${url}#article`,
headline: post.title,
description: post.brief,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
"@type": "Person",
"@id": PERSON_ID, // joins the Person node from the root layout
name: "Jane Doe",
url: SITE_URL,
},
mainEntityOfPage: { "@id": `${url}#webpage` },
},
{
"@type": "WebPage",
"@id": `${url}#webpage`,
url,
isPartOf: { "@id": WEBSITE_ID },
breadcrumb: { "@id": `${url}#breadcrumb` },
},
{
"@type": "BreadcrumbList",
"@id": `${url}#breadcrumb`,
itemListElement: crumbs.map((crumb, i) => ({
"@type": "ListItem",
position: i + 1,
...crumb,
})),
},
],
};
return (
<article>
<JsonLd data={graph} />
<h1>{post.title}</h1>
</article>
);
}The @id references turn three separate blobs into one entity graph: this article, on this page, in this website, by this person. Author name and url are repeated because Google’s Article documentation lists them as recommended author properties. Article markup has no required fields, but headline, image (add it when a post has a cover), datePublished and dateModified are recommended; use ISO 8601 dates with a timezone. Never change a published @id, and never mark up content the reader can’t see. If you’re thinking about how AI answer engines use this markup, I cover that side in SEO in the age of AI search.
How to verify your Next.js SEO setup
View source, not DevTools. Open
view-source:on a production build, orcurlit. Check one<title>, one absolute canonical, the fullog:*set and your JSON-LD in the raw HTML, which is what HTML-limited crawlers get.Rich Results Test. Paste a URL into Google’s Rich Results Test and fix every error and warning. The Schema Markup Validator shows the full graph, including types Google doesn’t use for rich results.
Search Console URL Inspection. In the indexed result (the live test can’t show Google’s choice), compare the user-declared canonical with the Google-selected canonical. If they differ, Google picked another URL as the main version, so check that your internal links, redirects and sitemap all agree with your canonical.
Submit the sitemap in Search Console and confirm it reads every URL.
Frequently Asked Questions
Does Next.js handle SEO automatically?
Next.js renders server-side HTML and generates <head> tags from your metadata exports, but it doesn’t decide your titles, canonicals, sitemap dates or structured data. Out of the box you only get the charset and viewport meta tags. A complete Next.js SEO setup still needs metadataBase, per-page generateMetadata, sitemap.ts, robots.ts and JSON-LD.
Should I use generateMetadata or the metadata object?
Use the static metadata object when the values never depend on data, as in a root layout or an About page. Use generateMetadata when they come from route params or a database, as on a blog post. A single route segment can’t export both.
Do sitemap priority and changefreq matter for Google?
No. Google ignores <priority> and <changefreq> in sitemaps and uses <lastmod> only when it is consistently accurate. In a Next.js sitemap.ts, the field worth getting right is lastModified, set from the real last edit rather than new Date().
Where should JSON-LD go in a Next.js App Router app?
Render it as a native <script type="application/ld+json"> tag in a layout or page component, with < escaped as \u003c. Put site-wide nodes such as Person and WebSite in the root layout and page-specific nodes such as BlogPosting in the page, linked by @id.
Putting it into practice
Set metadataBase and a title template once, give every page its own canonical and a full openGraph object, date your sitemap from real edits, use noindex rather than robots.txt to keep pages out of results, and ship JSON-LD as one connected, escaped graph. Then verify it in view-source and Search Console. Technical SEO only gets you indexed and understood; page speed is the other half, covered in my Core Web Vitals guide. More App Router guides live in the Next.js category.



