A Next.js SEO checklist for the App Router comes down to five jobs: complete metadata from the Metadata API, one canonical URL per page, a sitemap.xml and robots.txt generated from code, indexable content in the server-rendered HTML, and correct status codes for redirects and missing pages. Add JSON-LD, fast images and fonts, and hreflang if you publish in more than one language, then test what crawlers actually receive.
The code below follows the documentation for Next.js 16.3, the current release as of September 2026, where route params is a Promise you await and middleware.ts is now proxy.ts. Still choosing a framework? Start with our React vs Next.js comparison.
Next.js SEO checklist at a glance
| # | Check | Next.js tool | Done when |
|---|---|---|---|
| 1 | Site-wide metadata | metadataBase, title template | Unique title and description per page |
| 2 | Metadata from data | generateMetadata | Dynamic pages describe themselves |
| 3 | Canonical URLs | alternates.canonical | Each page points to its clean URL |
| 4 | Social previews | openGraph, opengraph-image | Shared links show the right card |
| 5 | Sitemap | app/sitemap.ts | All indexable URLs, real dates |
| 6 | Robots | app/robots.ts, robots metadata | Private areas blocked, staging noindex |
| 7 | Rendering | Static routes, generateStaticParams, Suspense | Main content in the initial HTML |
| 8 | Structured data | JSON-LD script tag | Passes the Rich Results Test |
| 9 | Images and fonts | next/image, next/font | LCP image preloaded, no layout shift |
| 10 | Core Web Vitals | useReportWebVitals | LCP, INP and CLS pass |
| 11 | Languages | alternates.languages, app/[lang] | All versions linked, plus x-default |
| 12 | Redirects and 404s | redirects, permanentRedirect, notFound | 308 for moves, 404 for missing pages |
How to set metadata in the Next.js App Router
Set the defaults once in the root layout. metadataBase lets every other URL field use a relative path, and a title template keeps titles consistent:
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
metadataBase: new URL('https://www.example.com'),
title: {
template: '%s | Example Co',
default: 'Example Co',
},
description: 'What the site offers, in one plain sentence.',
openGraph: { siteName: 'Example Co', type: 'website' },
}
The metadata object and generateMetadata work only in Server Components, and a route segment can export one, not both. By default Next.js adds just the charset and viewport tags.
generateMetadata for dynamic pages
For posts, products and other pages built from data, read the route params and return metadata from the same data the page uses. Wrap the data function in React's cache so the page and its metadata share one request:
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPost } from '@/app/lib/data' // your data function, wrapped in cache()
type Props = { params: Promise<{ slug: string }> }
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
if (!post) notFound()
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: '/blog/' + slug },
openGraph: { siteName: 'Example Co', type: 'article', publishedTime: post.publishedAt },
}
}
Here getPost, getAllPosts and the post fields stand in for your own data layer. Metadata merges shallowly: a page's openGraph object replaces the layout's, so siteName is repeated. For per-page share images, add an opengraph-image.tsx file; file-based metadata overrides the metadata object.
Since Next.js 15.2, metadata on dynamically rendered pages can stream in after the first HTML. The docs say Googlebot reads it correctly, while HTML-limited bots such as Twitterbot, Slackbot and Bingbot are detected by user agent and get the tags in the <head> first. Prerendered pages resolve metadata at build time.
Canonical URLs in Next.js
Give every indexable page a self-referencing canonical with alternates.canonical, as above. With metadataBase set, the relative path becomes an absolute URL, which is what Google recommends. Then remove the duplicates a canonical has to fight:
- Pick one host (www or not) and one protocol, and redirect the others.
- Keep trailing slashes consistent. By default Next.js redirects
/about/to/about;trailingSlash: truereverses that. - Leave tracking parameters out of canonical URLs.
Google treats a canonical as a strong signal, not a command, and advises against using robots.txt or noindex to choose one.
sitemap.ts and robots.ts
Generate both files from code. app/sitemap.ts returns an array of URLs served at /sitemap.xml:
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { getAllPosts } from '@/app/lib/data'
const BASE_URL = 'https://www.example.com'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts()
return [
{ url: BASE_URL },
...posts.map((post) => ({
url: BASE_URL + '/blog/' + post.slug,
lastModified: post.updatedAt,
})),
]
}
Use real change dates for lastModified: Google ignores priority and changefreq and uses lastmod only when it is consistently accurate. One sitemap holds at most 50,000 URLs or 50 MB uncompressed; split larger ones with generateSitemaps, whose id argument is a Promise since Next.js 16.
app/robots.ts builds robots.txt the same way:
// app/robots.ts
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/', disallow: '/admin/' },
sitemap: 'https://www.example.com/sitemap.xml',
}
}
Robots.txt controls crawling, not indexing: Google says it will not keep a page out of search, so use noindex or a password. On staging, set robots: { index: false } in the root layout metadata from an environment variable.
Static, dynamic or streaming: what crawlers see
Every App Router page is rendered to HTML on the server. What changes is when:
- Static routes are prerendered at build time. For dynamic segments,
generateStaticParamslists the paths to prerender. - Dynamic routes render per request, for example when they read cookies, headers or search params.
- Streaming, through
loading.jsor Suspense, sends HTML in chunks within one response; the docs say it is server-rendered and does not affect SEO.
The catch is status codes. Once streaming starts, the server has already sent 200 OK. A notFound() that fires mid-stream cannot become a 404, so Next.js adds a noindex robots tag instead, and a redirect() mid-stream becomes a client-side redirect. Check that the page exists before any Suspense boundary:
// app/blog/[slug]/page.tsx (same file as generateMetadata)
import { Suspense } from 'react'
import { Comments } from './comments'
export default async function Page({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
if (!post) notFound() // real 404, before any Suspense boundary
return (
<article>
<h1>{post.title}</h1>
<div>{post.body}</div>
<Suspense fallback={<p>Loading comments...</p>}>
<Comments postId={post.id} />
</Suspense>
</article>
)
}
With Cache Components on (opt-in in Next.js 16), HTML-limited bots get the page rendered on request rather than the prerendered shell, so shell data must also work at request time. Content fetched in the browser after load is not in the initial HTML, and not every bot runs JavaScript, so Google still recommends server-side rendering or prerendering; see our JavaScript SEO guide.
How to add JSON-LD structured data in Next.js
The Next.js docs recommend rendering JSON-LD as a native <script> tag in a layout or page, not with next/script, and replacing < to prevent script injection. Inside the page component:
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: { '@type': 'Person', name: post.authorName },
}
return (
<article>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
}}
/>
<h1>{post.title}</h1>
</article>
)
Google recommends JSON-LD, but markup makes a page eligible for rich results, not guaranteed to get them. Mark up only what is visible, then validate with the Rich Results Test and Schema Markup Validator. Our structured data guide lists the types worth adding.
Images, fonts and Core Web Vitals
Google's Core Web Vitals targets are LCP within 2.5 seconds, INP of 200 milliseconds or less and CLS of 0.1 or less, at the 75th percentile of page loads. The hero image and fonts matter most:
// app/hero.tsx
import Image from 'next/image'
import heroImage from './hero.jpg'
export function Hero() {
return <Image src={heroImage} alt="Team planning a launch at a whiteboard" preload />
}
- Fonts. Load them with
next/fontin the root layout: it self-hosts Google and local fonts, so browsers make no requests to Google, and adds a fallback font to reduce layout shift. - LCP image. Next.js 16 deprecated
priorityin favor ofpreload. Preload the one hero image that is your LCP element and keep it outside Suspense boundaries; if the LCP image changes by screen size, useloading="eager", becausenext/imagelazy-loads by default. - Layout shift. Give images width and height (static imports do it for you), add
sizesto responsive images, and make loading skeletons the same size as the content they replace. - Alt text.
altis required; use an empty string only for decorative images.
For real-user numbers, report metrics with useReportWebVitals from next/web-vitals in a small Client Component, then compare them with Search Console and PageSpeed Insights. More fixes are in our guide to speeding up a slow website.
hreflang and internationalization in Next.js
The App Router has no built-in locale routing. The documented pattern nests routes under app/[lang], redirects visitors without a locale in proxy.ts, prerenders each locale with generateStaticParams and sets <html lang> in the root layout; since Next.js 16.3, next/root-params lets any Server Component read lang. Then declare every language version:
// app/[lang]/blog/[slug]/page.tsx
import type { Metadata } from 'next'
type Props = { params: Promise<{ lang: string; slug: string }> }
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { lang, slug } = await params
return {
alternates: {
canonical: '/' + lang + '/blog/' + slug,
languages: {
'en-US': '/en-US/blog/' + slug,
'de-DE': '/de-DE/blog/' + slug,
'x-default': '/en-US/blog/' + slug,
},
},
}
}
Google's rules: each version lists itself and all the others, links must point both ways or they are ignored, and URLs must be fully qualified, which metadataBase handles. Use x-default for the fallback. sitemap.ts can declare the same pairs with alternates.languages; Google treats the methods as equivalent, so one is enough.
Redirects and 404 pages in Next.js
Redirect moved URLs on the server. In next.config.ts, permanent: true returns a 308, which Google treats as a permanent move, like a 301:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
async redirects() {
return [
{ source: '/blog/:slug', destination: '/articles/:slug', permanent: true },
]
},
}
export default nextConfig
- Config redirects run before Proxy and before rendering, so they always send a real status code.
- When a slug changes at runtime, call
permanentRedirect(), which sends a 308. Plainredirect()sends a temporary 307. - For missing content, call
notFound()and add anot-found.tsxwith links back into the site. Next.js adds a noindex tag to 404 pages, so even a streamed 404 that returns 200 stays out of the index.
Moving an existing site to Next.js? Map every old URL first; our guide to migrating without losing rankings covers the process.
How to test Next.js SEO before and after launch
Start with the build output: next build marks each route ○ static, ● prerendered with generateStaticParams, ƒ rendered on demand, or ◐ partially prerendered with Cache Components. Then fetch pages the way a simple crawler does:
curl -s https://www.example.com/blog/my-post | grep -o '<link rel="canonical"[^>]*>'
curl -sI https://www.example.com/blog/old-slug
Check that:
- The title, description, canonical, hreflang, JSON-LD and main text are in the raw HTML.
- Moved URLs return 308 with the right location; missing URLs return 404.
/robots.txtand/sitemap.xmlwork on production, not just locally.- Search Console's URL Inspection tool shows the right rendered HTML, and the sitemap is submitted there.
- Key templates still pass the Rich Results Test and PageSpeed Insights after each release that changes routes.
Want a second pair of eyes before launch? Send us the URL and we will check what crawlers see. It is part of our web development work.
Next.js SEO FAQ
Is Next.js good for SEO?
Yes, when you use it as designed. The App Router renders pages to HTML on the server, the Metadata API builds titles, canonicals and social tags, and sitemap and robots files come from code. Most Next.js SEO problems come from content fetched only in the browser, missing canonicals or wrong status codes, not from the framework itself.
Does streaming hurt SEO in Next.js?
Not by itself. Streamed HTML is still rendered on the server, and the Next.js docs say Googlebot reads streamed metadata, while bots that cannot run JavaScript get metadata in the head. The real risk is the status code: once streaming starts the response is already a 200, so check that content exists before any Suspense boundary when you need a true 404.
Does Next.js add canonical tags automatically?
No. By default Next.js only adds the charset and viewport meta tags. You set the canonical yourself with alternates.canonical, usually inside generateMetadata for dynamic pages, and metadataBase turns relative paths into absolute URLs. Without a canonical, Google picks one for you, and it may not be the URL you want indexed.
How do I stop Google indexing my staging site?
Do not rely on robots.txt, because Google says it is not a way to keep pages out of search. Set a noindex rule through the robots field in your root layout metadata whenever the deployment is not production, or put staging behind a password. Then check production before launch so the noindex rule does not ship there by mistake.
What changed for SEO in Next.js 16?
The Metadata API works the same way, but several related details moved. Synchronous access to route params is gone, the props of opengraph-image and icon generators and the sitemap id are now Promises, middleware.ts is now proxy.ts, the image priority prop is deprecated in favor of preload, and AMP support was removed.
Sources
- Next.js 16.3 release notes (Next.js blog, 3 August 2026)
- generateMetadata (Next.js docs)
- sitemap.xml (Next.js docs)
- robots.txt (Next.js docs)
- Streaming (Next.js docs)
- JSON-LD (Next.js docs)
- Image component (Next.js docs)
- Font optimization (Next.js docs)
- useReportWebVitals (Next.js docs)
- Internationalization (Next.js docs)
- Redirecting (Next.js docs)
- notFound (Next.js docs)
- trailingSlash (Next.js docs)
- Building your application (Next.js docs)
- Build and submit a sitemap (Google Search Central)
- How to specify a canonical URL (Google Search Central)
- Introduction to robots.txt (Google Search Central)
- JavaScript SEO basics (Google Search Central)
- Introduction to structured data (Google Search Central)
- Localized versions of your pages (Google Search Central)
- Redirects and Google Search (Google Search Central)
- Web Vitals (web.dev)
Loading comments...