Next.js gives you everything you need to build a site that ranks well, and almost as many ways to quietly undo it. Most App Router SEO problems don't come from a missing feature. They come from a default that behaves differently from what you assumed — metadata that doesn't merge the way you'd expect, a route that went dynamic without anyone noticing, a 404 page that returns 200.
This is the checklist I work through on every Next.js site, roughly in order of how much damage each item does when it's wrong.
1. Make sure the content is in the HTML
Google renders JavaScript, but it does so in a second pass that can lag behind crawling, and other search engines and most AI crawlers don't render at all. The safe rule is that anything you want indexed — body text, headings, internal links, structured data — should be in the HTML the server sends.
The App Router makes this the default: Server Components render on the server, and Client Components also render to HTML on the server before hydrating. The trouble starts with patterns that only produce content in the browser:
- Data fetched in a
useEffect— the server HTML contains only the loading state. - Components wrapped in
dynamic(() => import(...), { ssr: false }). - Content that renders only after checking
windowor a media query in JavaScript. - Tabs and accordions that don't render hidden panels at all until clicked.
The quickest check is curl: fetch the page and search the raw HTML for a sentence from the body. If it isn't there, neither is it for any crawler that doesn't run JavaScript.
curl -s https://example.com/services | grep -c "a sentence from your page"2. Know which routes are static
A static route is pre-rendered at build time and served from the CDN — fast TTFB, fast LCP, and identical HTML for every crawler. Calling cookies(), headers(), reading searchParams, or making an uncached fetch opts the route into rendering per request. That's sometimes necessary, but it's often accidental — one analytics helper that reads a cookie inside the root layout can make the entire site dynamic.
Check the route table printed by next build after every significant change. For dynamic segments like /blog/[slug], return every known slug from generateStaticParams so they are pre-rendered, and set export const dynamicParams = false if unknown slugs should 404 rather than render on demand.
3. Metadata: titles, descriptions and the merge gotcha
Set a metadataBase and a title template once in the root layout:
// app/layout.js
export const metadata = {
metadataBase: new URL("https://www.example.com"),
title: {
default: "Example — Web Development",
template: "%s — Example",
},
description: "…",
};Each page then exports its own metadata (or generateMetadata for dynamic routes) with a short, unique title — the template adds the brand suffix. Keep the full title under about 60 characters and the description under about 155, or Google truncates them.
Now the gotcha that catches almost everyone: metadata merges shallowly, per top-level key. If the root layout sets openGraph with an image, and a page sets its own openGraph with just a title, the page's object replacesthe layout's — and the image is gone. The page has no og:image at all, and nothing warns you. The same applies to twitter.
I found exactly this on this site: every page except the homepage was shipping without a share image because each one set its own openGraph title. The fix is to build page metadata through one helper that always includes the shared fields:
// lib/seo.js
const ogImage = { url: "/opengraph-image", width: 1200, height: 630 };
export function pageMetadata({ title, description, path }) {
return {
title,
description,
alternates: { canonical: path },
openGraph: { title, description, url: path, images: [ogImage] },
twitter: { card: "summary_large_image", title, description },
};
}Streaming metadata
Since Next.js 15.2, generateMetadatacan stream: for a route rendered at request time, the page's UI is sent first and the metadata tags are appended when they resolve. Googlebot executes JavaScript and reads them correctly; for “HTML-limited” bots such as social media link-preview crawlers, Next.js detects the user agent and blocks on metadata so it lands in the <head> as usual. You can widen that list with the htmlLimitedBotsoption if a crawler you care about isn't on it.
4. Canonical URLs
Every indexable page should declare its canonical URL, and it should be the exact URL you want in search results — same protocol, same host, same trailing-slash policy as your redirects. With a metadataBase set, a relative path is enough:
export const metadata = {
alternates: { canonical: "/services" },
};Watch for pages reachable at several URLs — with and without query parameters, with and without www, filtered listing pages — and make sure they all point to one canonical. Redirect the non-canonical host at the platform level (Vercel's domain settings do this) rather than in application code.
5. Sitemap and robots.txt, generated from your data
app/sitemap.js and app/robots.jsgenerate both files at build time. The key is to generate the sitemap from the same data your routes render from, so it can never list a page that doesn't exist:
// app/sitemap.js
import { SITE_URL } from "@/lib/site";
import { POSTS } from "@/lib/blog";
export default function sitemap() {
return [
{ url: SITE_URL, priority: 1 },
{ url: `${SITE_URL}/blog`, priority: 0.7 },
...POSTS.map((post) => ({
url: `${SITE_URL}/blog/${post.slug}`,
lastModified: post.dateModified || post.datePublished,
})),
];
}Leave out lastModified unless you have a real date. Setting it to new Date()on every build tells Google every page changed on every deploy, and Google's guidance is that it stops trusting a lastmod that is consistently inaccurate.
Sitemaps for large sites
A single sitemap file is capped at 50,000 URLs. At Bigbasket the catalogue ran past 500,000 product URLs, and regenerating the sitemaps took three hours; automating the build as a scheduled cron job brought that down to eight minutes and improved crawl efficiency by about 15%. Fresh, accurate sitemaps on a schedule matter more on a big catalogue than almost anything else in this list.
In the App Router, generateSitemaps does the splitting for you: return one id per file, and the sitemap function receives that id (as a promise, in Next.js 16) to build each chunk.
// app/product/sitemap.js
const PER_FILE = 50_000;
export async function generateSitemaps() {
const total = await countProducts();
return Array.from({ length: Math.ceil(total / PER_FILE) }, (_, id) => ({ id }));
}
export default async function sitemap({ id }) {
const page = Number(await id);
const products = await getProducts({ offset: page * PER_FILE, limit: PER_FILE });
return products.map((p) => ({
url: `https://www.example.com/product/${p.slug}`,
lastModified: p.updatedAt,
}));
}In robots.js, don't disallow /_next/static/ — Google needs your CSS and JavaScript to render the page. Remember too that robots.txt controls crawling, not indexing: to keep a page out of results, use robots: { index: false }in its metadata, and don't also block it in robots.txt, or Google never sees the noindex.
6. Status codes that tell the truth
- Call
notFound()when a dynamic route's data doesn't exist, so the response is a real 404 and not a 200 with “not found” text on it (a “soft 404”). - Use
permanent: trueredirects innext.config.jsfor moved pages — Next.js sends a 308, which search engines treat like a 301 and pass ranking signals through. - After a redesign or migration, map every old URL that had traffic or links to its new equivalent. Losing those is the most common way a relaunch loses its search traffic.
7. Structured data, as one linked graph
Render JSON-LD from a Server Component so it's in the initial HTML. Two details matter more than the choice of types:
Escape <. JSON.stringifydoesn't escape it, so any string containing </script> — from a CMS field, say — would close the script tag early.
export default function JsonLd({ data }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(data).replace(/</g, "\\u003c"),
}}
/>
);
}Give each entity one node and one @id. Define the organisation and the person once, in the root layout, and have every page-level node — Service, BlogPosting, BreadcrumbList — reference them by @id instead of restating them. Two slightly different copies of your business read to a crawler as two businesses.
And only mark up what's visible. Build FAQPage or Productdata from the same array the component renders, so the markup can't drift from the page.
8. Social images
An opengraph-image.js file in a route segment generates the share image with ImageResponse and wires up the og:image tags automatically — but see the merge gotcha above: once a page sets its own openGraph object, it must include the image too.
9. Performance is part of SEO
Core Web Vitals are a ranking signal, and more importantly, slow pages lose visitors before they convert. The App Router gives you the tools — static rendering, Server Components, next/image, next/font — but it doesn't use them for you. There's a full walkthrough in how to fix Core Web Vitals in Next.js.
The short version
- Content, links and JSON-LD in the server HTML — check with
curl. - Marketing routes static; check the
next buildroute table. - Title template and
metadataBasein the root layout; unique title and description per page. - One metadata helper, so
openGraphandtwitternever lose their images. - A canonical on every indexable page, matching your redirects.
- Sitemap generated from route data; no fake
lastModified. - Real 404s via
notFound(); permanent redirects for moved URLs. - One linked structured-data graph, escaped, matching visible content.
If you'd like this checklist run against your own site, with the fixes implemented, that's what Next.js development and SEO work covers.