Is Next.js good for SEO?
Yes, when you use it for what it is good at. In the App Router, pages are Server Components by default, and routes that do not need request-time data are prerendered at build time. The crawler then receives full HTML, with the content, links and metadata already in place. Google recommends server-side rendering, static rendering or hydration over workarounds, and many other crawlers do not run scripts at all. Our guide to JavaScript SEO explains why.
Next.js does not do SEO for you. It gives you the tools, and the mistakes are usually configuration mistakes. The code below follows the Next.js 16 documentation. In Next.js 15 and 16, params is a Promise, so you await it.
How do you set titles, descriptions and canonicals with the Metadata API?
Export a metadata object for static values or a generateMetadata function for data-driven ones. Both are only supported in Server Components. Put the shared parts in the root layout.
import type { Metadata } from 'next'
export const metadata: Metadata = {
metadataBase: new URL('https://example.com'),
title: {
default: 'Example',
template: '%s | Example',
},
description: 'Example builds invoicing software for freelancers.',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPost } from '@/lib/posts'
type PageProps = { params: Promise<{ slug: string }> }
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
if (!post) {
return {}
}
return {
title: post.title,
description: post.summary,
alternates: { canonical: `/blog/${slug}` },
openGraph: { type: 'article', title: post.title, description: post.summary },
}
}
export default async function Page({ params }: PageProps) {
const { slug } = await params
const post = await getPost(slug)
if (!post) {
notFound()
}
return <h1>{post.title}</h1>
}- Set metadataBase once. It lets you use relative URLs in fields such as
alternatesandopenGraph. Without it, a relative URL causes a build error, as the generateMetadata reference explains. - A title template applies to child segments only.
title.templatedoes not affect a title in apage.tsxnext to the layout that defines it, and it needs atitle.default. - Metadata is merged shallowly. A nested object such as
openGraphorrobotsdefined in a page replaces the one from the layout. Repeat every field you still want. - A canonical in the root layout is inherited. Every page without its own
alternateswould canonicalise to that URL. Set canonicals per page. - Share data fetching. Wrap
getPostin React’scachesogenerateMetadataand the page do not fetch twice.
How do you generate a sitemap and robots.txt in Next.js?
Add app/sitemap.ts and app/robots.ts. Both are special route handlers that are cached by default unless they use request-time APIs, according to the sitemap and robots references.
import type { MetadataRoute } from 'next'
import { getAllPosts } from '@/lib/posts'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts()
return [
{ url: 'https://example.com', lastModified: '2026-10-01' },
...posts.map((post) => ({
url: `https://example.com/blog/${post.slug}`,
lastModified: post.updatedOn,
})),
]
}import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/', disallow: ['/api/', '/admin/'] },
sitemap: 'https://example.com/sitemap.xml',
}
}- Use real dates. Google ignores the priority and changefreq values and uses
<lastmod>only if it is consistently and verifiably accurate.new Date()for every URL fails that test. - Stay within the limits. A sitemap may hold 50,000 URLs or 50 MB uncompressed. Split larger sites with
generateSitemaps. - List canonical, indexable URLs only. Leave out redirects,
noindexpages and error pages. - Do not use robots.txt to hide pages. Google says it is not a mechanism for keeping a page out of Google. Use
noindexor authentication.
Run the generated file through the free sitemap checker, then submit the sitemap URL in Search Console. Google treats a submitted sitemap as a suggestion and does not guarantee it will download or use it, so strong internal links still matter.
How do you add JSON-LD and Open Graph images?
Next.js recommends rendering JSON-LD as a plain <script> tag in a layout or page. JSON.stringify does not sanitise strings, so replace < with its unicode escape to prevent injection. Validate the result in Google’s Rich Results Test or with the free schema validator.
import { notFound } from 'next/navigation'
import { getPost } from '@/lib/posts'
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await getPost(slug)
if (!post) {
notFound()
}
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
datePublished: post.publishedOn,
dateModified: post.updatedOn,
}
return (
<article>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
}}
/>
<h1>{post.title}</h1>
</article>
)
}For social previews, add an opengraph-image file to a route segment. A static .png or .jpg works, and so does a .tsx file that returns an ImageResponse. Generated images are statically optimised at build time unless they use request-time APIs. A more specific segment overrides the one above it, so a file in app/blog/[slug] wins over the one in app.
import { ImageResponse } from 'next/og'
export const alt = 'Example, invoicing for freelancers'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default function Image() {
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 96,
background: 'white',
}}
>
Example
</div>
),
{ ...size },
)
}Which rendering mode should each page use?
| Page type | Approach | Notes |
|---|---|---|
| Blog, docs and marketing pages | Prerender at build with generateStaticParams | Complete HTML. With dynamicParams = false, unknown slugs return 404 |
| Large catalogue | Prerender a subset and render the rest on first visit | Return a partial list from generateStaticParams. Other pages are rendered when first requested, as documented |
| Personalised pages such as carts and dashboards | Dynamic rendering | Keep them out of the sitemap and add noindex |
| Pages with a few dynamic parts | Cache Components with <Suspense> | With cacheComponents: true, static and cached content ships in the initial HTML and runtime data streams in behind fallbacks |
import { getAllPosts } from '@/lib/posts'
export const dynamicParams = false
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({ slug: post.slug }))
}Watch metadata on dynamic pages. For dynamically rendered pages, Next.js streams metadata and appends it to the body once generateMetadata resolves. Bots that run JavaScript, such as Googlebot, read it correctly. For crawlers on a built-in list of HTML-limited bots, which includes Bingbot, Twitterbot and Slackbot, the metadata still blocks rendering and lands in <head>. Prerendered pages resolve metadata at build time and do not stream. If you need metadata in the head for every crawler, set the htmlLimitedBots option to /.*/. The documentation warns that overriding it can lead to longer response times.
How do canonical URLs and hreflang work in Next.js?
Use alternates.canonical and alternates.languages. Google requires that each language version lists itself and all other versions, with fully qualified URLs. Annotations without matching return links are ignored. x-default is the fallback for visitors who match no language. Set the same alternates on every version, and use alternates.languages in sitemap.ts if you prefer the sitemap method.
import type { Metadata } from 'next'
export const metadata: Metadata = {
alternates: {
canonical: '/en/pricing',
languages: {
'en-GB': '/en/pricing',
'de-DE': '/de/pricing',
'x-default': '/en/pricing',
},
},
}How do you keep images and fonts from hurting Core Web Vitals?
next/image uses width and height to reserve space, which avoids layout shift. Images load lazily by default, so the hero image needs the opposite. Since Next.js 16 the priority prop is deprecated in favour of preload, but the documentation says that in most cases loading="eager" or fetchPriority="high" is the better choice. next/font downloads Google fonts at build time and self-hosts them, and adjustFontFallback is on by default to reduce layout shift. See our guide to the Core Web Vitals test for the thresholds.
import Image from 'next/image'
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'], display: 'swap' })
export default function Hero() {
return (
<section className={inter.className}>
<h1>Invoices that send themselves</h1>
<Image
src="/hero.webp"
alt="Product dashboard"
width={1200}
height={630}
loading="eager"
fetchPriority="high"
/>
</section>
)
}What are the most common Next.js SEO mistakes?
| Mistake | Effect | Fix |
|---|---|---|
Fetching page content in useEffect inside a Client Component | The HTML is empty for crawlers that do not render | Fetch in a Server Component |
Exporting metadata from a Client Component | Not supported | Move the export to a server layout or page |
Overriding openGraph or robots in a page | Fields from the layout disappear | Repeat the full object or build it with a helper |
| A canonical in the root layout | Every page canonicalises to one URL | Set alternates.canonical per page |
A staging noindex shipped to production | The site drops out of the index | Drive robots from an environment variable and check after each deploy |
| Unknown slugs render a “not found” message with 200 | Soft 404 pages | Call notFound() before streaming starts |
Navigation with router.push on a button | Crawlers find no link | Use next/link or <a href>, which Google can crawl |
new Date() as lastModified for every URL | Google stops trusting lastmod | Use the real update date |
How does Serpel’s codebase scan map Next.js routes to live pages?
The Serpel CLI can link your source to what is live. serpel scan runs locally and needs next in your package.json. It reads the routes in app/ and pages/, also under src/, and drops route groups such as (marketing). Dynamic segments like [slug] and catch-all segments stay in the route. Parallel routes, intercepted routes and private folders are not routes. It also reads static metadata, meaning title, description, alternates.canonical and robots written as strings or template literals without expressions, and it applies title.template from parent layouts. Anything computed, including generateMetadata, counts as unknown unless build output exists. If a .next or out folder holds HTML files, the scan also uses their title, description, headings and structured data types.
The scan never reads .env files, keys, node_modules, binary files or anything in .gitignore, and it sends extracted metadata, never source code. Use --dry-run to see exactly what would be sent. After an upload, Serpel matches each route with the pages of your latest completed crawl and your Search Console page data. A static route matches its exact path, and a dynamic route matches by segment with the most specific route winning.
serpel scan --dry-run
serpel scan --project <project-id> --waitThe result lists routes without a live page, live pages without a route, routes without metadata and titles or descriptions that differ between code and live site. An agent can read the same summary through the get_codebase_overview tool of the MCP server, which links a crawl finding to the file that renders it.
Next.js SEO best practices at a glance
- Set
metadataBase, a title template and a description in the root layout. - Give every page a unique title, description and canonical.
- Prerender indexable pages and return a real 404 for unknown slugs.
- Generate
sitemap.tswith reallastModifieddates androbots.tswithout hiding pages. - Add JSON-LD as a plain script tag and an
opengraph-imageper route. - Use
next/imageandnext/font, and keep the LCP image out of lazy loading. - Submit the sitemap in Search Console and keep a staging noindex out of production.
- Crawl the live site after each release.
Frequently asked questions
Is Next.js good for SEO?
Yes, when pages are prerendered or server-rendered, because crawlers then receive complete HTML with content, links and metadata. Problems come from client-side data fetching, wrong canonicals, missing 404 responses and metadata that is overridden by mistake.
How do I add meta tags in the Next.js App Router?
Export a metadata object or a generateMetadata function from a server layout.tsx or page.tsx. Next.js builds the head tags from them, and metadataBase lets you use relative URLs for canonicals and images.
Do I need next-sitemap or another package for a sitemap?
No. The App Router supports app/sitemap.ts and app/robots.ts natively, and generateSitemaps splits large sitemaps. A package is only worth adding if you need features the built-in files do not offer.
Why does my Next.js page show a 200 status for a missing slug?
The page probably renders a not-found message without calling notFound(), or calls it after streaming has started. Next.js then keeps the 200 status and relies on a noindex tag. Check for the slug before the page streams.
How can I check my Next.js SEO on the live site?
Crawl the site and compare it with your code. A site audit finds missing titles, wrong canonicals and soft 404s, and serpel scan shows routes without a live page or without metadata.
Sources
- Next.js documentation: Metadata and OG images, accessed 10 Oct 2026
- Next.js documentation: generateMetadata, accessed 10 Oct 2026
- Next.js documentation: sitemap.xml, accessed 10 Oct 2026
- Next.js documentation: robots.txt, accessed 10 Oct 2026
- Next.js documentation: opengraph-image and twitter-image, accessed 10 Oct 2026
- Next.js documentation: How to implement JSON-LD, accessed 10 Oct 2026
- Next.js documentation: generateStaticParams, accessed 10 Oct 2026
- Next.js documentation: Caching and Cache Components, accessed 10 Oct 2026
- Next.js documentation: htmlLimitedBots, accessed 10 Oct 2026
- Next.js documentation: Upgrading to version 15, accessed 10 Oct 2026
- Next.js documentation: Image component, accessed 10 Oct 2026
- Next.js documentation: Font module, accessed 10 Oct 2026
- Google Search Central: Build and submit a sitemap, accessed 10 Oct 2026
- Google Search Central: Introduction to robots.txt, accessed 10 Oct 2026
- Google Search Central: Localized versions of your pages, accessed 10 Oct 2026
- Google Search Central: Dynamic rendering as a workaround, accessed 10 Oct 2026
- Google Search Central: Make your links crawlable, accessed 10 Oct 2026
Related reading
- JavaScript SEO: how Google crawls, renders and indexes JavaScriptJavaScript SEO explained: how Googlebot crawls, renders and indexes JS, which rendering strategy to choose and how to test what Google sees.
- Core Web Vitals test: how to measure and fix LCP, INP and CLSRun a Core Web Vitals test with PageSpeed Insights, Search Console, Lighthouse, CrUX and Serpel, then fix LCP, INP and CLS with a table of causes.
- Site auditSerpel runs a technical SEO audit with 94 checks, JavaScript rendering and Core Web Vitals, and explains every issue and its fix.
- CLIThe Serpel SEO CLI runs rank checks, site audits and AI visibility checks from your terminal, with JSON output, documented exit codes and CI support.
