In short
- Make sure your main content is in the server HTML. Fetch data in Server Components, not in useEffect.
- Set metadataBase once, then give every page its own title, description and alternates.canonical. Next.js won't add a canonical for you.
- Generate app/sitemap.ts and app/robots.ts from code, and never block /_next/.
- Call notFound() before any Suspense boundary or loading.tsx, or the status is already 200. Redirect renamed URLs permanently in config or proxy.
- Make robots.txt environment-aware so previews stay out of Google, and give every language and paginated page its own canonical.
Next.js does a lot of SEO work for you: pages render on the server by default, and the Metadata API writes your <title>, description and canonical tags. Most problems on Next.js sites come from a handful of gaps: no canonical, a missing metadataBase, content that only loads in the browser, or a sitemap nobody updates. This checklist covers those gaps in the order that matters, with code for the App Router (Next.js 15 and 16).
What matters, in order
Search engines need four things from every page you want found. Fix them in this order:
- Real HTML. The text, headings and links are in the server response, not added later by JavaScript.
- One URL per page. A canonical tag, consistent trailing slashes and permanent redirects, so signals don't split across duplicates.
- A clear title and description that say what the page is about, unique per page.
- A way to be discovered: internal links and a sitemap.
Speed, structured data and images come after these. They help, but they don't rescue a page Google can't read or index.
Make sure the HTML has your content
Server Components render on the server, so their content is in the HTML. The risk is a page that fetches its main content inside a "use client" component with useEffect: the server sends an empty shell, and crawlers that don't run JavaScript see nothing. Many AI crawlers fetch raw HTML only, and Google renders JavaScript later and not always fully.
Check what the server sends, not what your browser shows:
curl -s https://your-site.com/pricing | grep -o "<h1[^>]*>[^<]*" | headIf the H1 or your main copy is missing, move the data fetching into a Server Component (or a page.tsx that awaits the data) and pass it to client components as props.
// Server Component: the plans are in the HTML Google receives.
export default async function PricingPage() {
const plans = await getPlans();
return (
<main>
<h1>Pricing</h1>
<PlanCards plans={plans} /> {/* can be a client component */}
</main>
);
}Titles, descriptions and canonicals
Set metadataBase once
In the root layout, set metadataBase to your production URL. Without it, relative URLs in canonicals and Open Graph images can't be made absolute. Add a title template so every page gets your brand without repeating it.
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL("https://your-site.com"),
title: { default: "Acme", template: "%s | Acme" },
description: "Project planning for small teams.",
};Give every page its own title, description and canonical
Next.js does not add a canonical tag for you. Set alternates.canonical on each page. For dynamic routes, use generateMetadata. (When you need a canonical and the mistakes to avoid: canonical tags, explained.)
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return {
title: post.title, // becomes "Post title | Acme"
description: post.summary,
alternates: { canonical: `/blog/${slug}` },
openGraph: { title: post.title, description: post.summary, type: "article" },
};
}| Tag | Aim for | Common mistake |
|---|---|---|
| Title | 50–60 characters, the page's main topic first | The same title on every page |
| Description | 140–160 characters that answer “why click?” | Left empty, so Google picks random text |
| Canonical | The page's own clean URL | Missing, or pointing every page at the homepage |
| H1 | One per page, close to the title | Logo text as the only H1 |
Know that metadata merges shallowly
Metadata from the layout and the page is merged one level deep. If a page sets openGraph, it replaces the layout's whole openGraph object, so the shared image and site name disappear. The same goes for alternates and robots. Keep shared nested fields in a module and spread them in:
export const baseOpenGraph = { siteName: "Acme", images: ["/og/default.png"] };
// in a page:
openGraph: { ...baseOpenGraph, title: post.title, type: "article" },For share images, you can also drop an opengraph-image.png (or a generated opengraph-image.tsx) into a route folder, with an opengraph-image.alt.txt next to it. Next.js writes the tags, and the build fails if the file is over 8 MB. Never set a canonical in the root layout: every page that doesn't override it inherits the homepage canonical.
Sitemap and robots.txt
Generate both from code so they never drift from your routes. List only canonical URLs that return 200 and aren't noindex.
import type { MetadataRoute } from "next";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getPosts();
return [
{ url: "https://your-site.com/", lastModified: new Date() },
{ url: "https://your-site.com/pricing" },
...posts.map((p) => ({
url: `https://your-site.com/blog/${p.slug}`,
lastModified: p.updatedAt,
})),
];
}import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: "*", allow: "/", disallow: ["/api/", "/dashboard/"] },
sitemap: "https://your-site.com/sitemap.xml",
};
}Google accepts up to 50,000 URLs per sitemap file. Past that, split it with generateSitemaps.
Structured data
JSON-LD helps search engines and AI answers understand what a page is. Render it as a script in the page, escaping < so page data can't break out of the tag:
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: { "@type": "Person", name: post.author },
};
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
}}
/>Start with Organization on the homepage, Article on posts, Product with offers on product pages and BreadcrumbList on deep pages. Only mark up what is visible on the page, then validate it with the Rich Results Test. Our structured data guide covers which types are still worth adding.
404s, redirects and trailing slashes
Return a real 404, before streaming starts
Call notFound() when a record doesn't exist, so the page returns a 404 instead of a 200 with “not found” text (a soft 404). There is a catch: once the response starts streaming, the status is already 200 and can't change. Streaming starts when a Suspense fallback renders, and a loading.tsx wraps the page in one. A notFound() after that point still renders the 404 UI and adds noindex, but the HTTP status stays 200. Do a fast existence check before any await that streams and before any <Suspense> boundary:
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
if (!(await postExists(slug))) notFound(); // real 404: nothing has streamed yet
return (
<Suspense fallback={<p>Loading…</p>}>
<PostContent slug={slug} />
</Suspense>
);
}When the segment has a loading.tsx, the whole page is already inside a Suspense boundary, so move the check into the parent layout or into proxy.ts (the Next.js 16 name for middleware), which both run before the fallback. For routes built with generateStaticParams, export const dynamicParams = false makes every unlisted slug a 404.
Redirects and trailing slashes
- Use
permanent: true(a 308) innext.config.tswhen you rename a URL, and update internal links so they don't point at the redirect.permanent: falseis a 307. - By default Next.js redirects
/about/to/about;trailingSlash: trueflips it. Keep sitemap, canonicals and links in the same style. - Redirect the host you don't use (www or apex) in your host's domain settings, or with a
hashost rule as below.
const nextConfig = {
async redirects() {
return [
{ source: "/blog/old-slug", destination: "/blog/new-slug", permanent: true },
{
source: "/:path*",
has: [{ type: "host", value: "www.your-site.com" }],
destination: "https://your-site.com/:path*",
permanent: true,
},
];
},
};
export default nextConfig;A redirect() returns a 307, never a 308. Called mid-stream, it has the same problem as notFound(): it becomes a client-side redirect instead of an HTTP redirect. For a 308, use permanentRedirect() before streaming starts, or redirects in next.config or proxy.ts.
Preview deploys and staging
Preview and staging URLs serve a copy of your site. If they get indexed, they compete with production. On Vercel, preview deployments send X-Robots-Tag: noindex by default, except when you attach a custom domain to a non-production branch (a staging. domain, for example). Elsewhere, assume nothing is blocked. Make robots.txt depend on the environment:
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
if (process.env.VERCEL_ENV !== "production") {
return { rules: { userAgent: "*", disallow: "/" } };
}
return {
rules: { userAgent: "*", allow: "/", disallow: ["/api/", "/dashboard/"] },
sitemap: "https://your-site.com/sitemap.xml",
};
}VERCEL_ENV is production, preview or development. On other hosts, use your own variable. Two rules keep this safe: hardcode metadataBase and sitemap URLs to the production origin (never derive them from VERCEL_URL, which is the preview host), and check production after every deploy that the disallow didn't ship there:
curl -s https://your-site.com/robots.txt
curl -sI https://your-branch.vercel.app | grep -i x-robots-tagLanguages, pagination and filters
hreflang with alternates.languages
For a site in several languages, list every version on every version, including itself, plus an x-default for visitors who match none. Google ignores hreflang tags that aren't returned by the other page. Relative paths become absolute through metadataBase.
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params;
return {
alternates: {
canonical: `/${lang}/pricing`,
languages: { en: "/en/pricing", de: "/de/pricing", "x-default": "/en/pricing" },
},
};
}Each language version keeps its own canonical. Pointing /de/pricing at /en/pricing tells Google the German page is a duplicate. app/sitemap.ts entries accept the same alternates.languages object if you prefer hreflang in the sitemap.
Paginated and filtered lists
- Give
/blog?page=2its own canonical. Google says not to canonicalize page 2 onward to page 1. Link pages with real<Link href>elements, not a client-only "load more" button. - Sort and filter parameters create endless near-duplicates. Set their canonical to the unfiltered list, or add
robots: { index: false }whensearchParamscontains them. Leave them out of the sitemap.
Images and Core Web Vitals
Use next/image with width/height (or fill with sizes) so the layout doesn't shift. For the main image above the fold, use preload (Next.js 16 replaced priority) or fetchPriority="high". Give every meaningful image a short, descriptive alt; decorative ones get alt="".
Watch the three Core Web Vitals in Search Console: LCP (main content shows quickly), INP (the page responds to input) and CLS (nothing jumps). Large client bundles are the usual cause of poor INP on Next.js sites, so keep "use client" at the leaves of your component tree.
Internal links
Use <Link href> for navigation. It renders a real <a href> that crawlers can follow; a button with router.push is invisible to them. Link related pages to each other with descriptive anchor text, and make sure every important page is reachable within three clicks of the homepage. Pages that nothing links to (orphans) are found late or not at all. Our internal linking guide goes deeper.
Troubleshooting: symptom, cause, fix
| Symptom | Likely cause | Fix |
|---|---|---|
| Search Console: “Soft 404” on deleted posts | notFound() runs after a loading.tsx fallback, so the status is 200 | Check existence before any Suspense boundary, in a layout, or in proxy.ts |
| “Duplicate, Google chose different canonical than user” | Canonical set in the root layout, or relative without metadataBase | Per-page alternates.canonical, and metadataBase in the root layout |
| Share cards lost their image on some pages | A page set openGraph and replaced the layout's object | Spread shared Open Graph fields into each page's object |
| A preview or staging URL shows up in Google | Custom domain on a preview branch, or a host with no noindex default | Environment-aware robots.ts, or an X-Robots-Tag: noindex header |
| Production robots.txt says Disallow: / | The environment check fails in production (variable missing or misspelled) | curl robots.txt after each deploy; add it to CI |
| Google shows an old title weeks after a fix | The page is statically cached and wasn't rebuilt, or Google hasn't recrawled | curl the live page; revalidate or redeploy; request indexing in URL Inspection |
| Indexed, but the title is the site name only | generateMetadata returns {} on a failed fetch | Throw or call notFound() instead of returning empty metadata |
Check it before you ship
- The server HTML contains each page's H1 and main copy (
curlit). - Every indexable page has a unique title, a description and a self-referencing canonical.
metadataBaseis your production URL, not a preview URL./sitemap.xmllists only 200, canonical, indexable URLs, and robots.txt points to it.- Missing records return a real 404 status (
curl -sI), not a 200 with noindex; renamed URLs redirect permanently. - Preview URLs are noindex or disallowed, and production robots.txt is not.
- Language versions list each other and
x-default; paginated pages keep their own canonical. - JSON-LD validates in the Rich Results Test.
- Every page you care about has at least two internal links pointing to it.
For a quick look at one live page, paste its URL into the free SEO checker.
The most common regression is a preview deploy that ships with a broken canonical or a stray noindex. Catch it before it reaches production:
- Deploy a preview
Push your branch and wait for the preview URL (for example a vercel.app URL).
- Compare it with production
Terminal npx serpkin check https://your-branch.vercel.appSerpkin compares titles, canonicals, indexability, status codes, sitemap and structured data on the preview against production. It exits with code 1 on a regression, so it also works in CI.
- Fix, then merge
Your agent gets the exact files to change from the report.
Questions
Does Next.js handle SEO automatically?
Partly. Server rendering and the Metadata API give you a strong base, but Next.js does not add canonical tags, write unique titles, build your sitemap or link your pages for you. Those are on you or your agent.
Is client-side rendering bad for SEO?
Client components are fine for interactivity. The problem is main content that only appears after JavaScript runs in the browser. Google renders JavaScript later and not always fully, and many AI crawlers don't run it at all. Keep the content in the server HTML.
Should I use the App Router or the Pages Router for SEO?
Both can rank well. The App Router makes the good defaults easier: Server Components by default, the Metadata API, and file-based sitemap.ts and robots.ts. This checklist uses the App Router.
Why does my Next.js 404 page return status 200?
The response started streaming before notFound() ran, usually because the route has a loading.tsx or the call sits inside a Suspense boundary. Next.js then adds a noindex tag but can't change the status. Check that the record exists before any Suspense boundary, or in proxy.ts.
How do I add hreflang tags in Next.js?
Use alternates.languages in metadata or generateMetadata, for example { en: "/en/pricing", de: "/de/pricing", "x-default": "/en/pricing" }. Every language version must list all versions, including itself, and keep its own canonical.
Do I need structured data?
It is not required to rank, but it helps search engines and AI answers understand the page and can make it eligible for rich results. Add it after the basics: real HTML, canonicals, titles and a sitemap.
Sources
- Next.js docs: JSON-LD (checked October 27, 2026)
- Next.js docs: generateMetadata (merging, alternates, streaming metadata) (checked October 27, 2026)
- Next.js docs: loading.js (status codes when streaming) (checked October 27, 2026)
- Next.js docs: notFound (checked October 27, 2026)
- Next.js docs: htmlLimitedBots (checked October 27, 2026)
- Next.js docs: redirects (308/307, host matching) (checked October 27, 2026)
- Next.js docs: trailingSlash (checked October 27, 2026)
- Next.js docs: opengraph-image (checked October 27, 2026)
- Next.js docs: sitemap.ts (generateSitemaps, alternates) (checked October 27, 2026)
- Next.js docs: dynamicParams (checked October 27, 2026)
- Vercel: are preview deployments indexed? (checked October 27, 2026)
- Vercel: system environment variables (VERCEL_ENV) (checked October 27, 2026)
- Google: localized versions (hreflang) (checked October 27, 2026)
- Google: pagination and incremental loading (checked October 27, 2026)
- Next.js docs: Image (preload replaces priority in v16) (checked October 27, 2026)