Next.js SEO optimization guide. Use when building Next.js apps, optimizing for search engines, fixing Google indexing issues, implementing metadata, sitemaps, robots.txt, JSON-LD, or auditing SEO.
Comprehensive SEO guide for Next.js App Router applications.
Read the installed Next.js version and relevant node_modules/next/dist/docs/
guides before changing framework code; use current official docs when local
docs are unavailable. Use Vercel MCP documentation search for platform behavior
when available, but check retrieved examples against the installed framework.
Do not copy a legacy cache API from a search snippet into a newer app.
Run this checklist for any Next.js project:
curl https://your-site.com/robots.txtcurl https://your-site.com/sitemap.xml<title> and <meta name="description">application/ld+jsonimport type { Metadata, Viewport } from 'next';
// Viewport must be a separate export โ `themeColor`, `colorScheme`, and
// `viewport` inside the `metadata` object are deprecated (since v14: still
// emitted with a warning today, not guaranteed to stay).
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 5,
userScalable: true,
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
{ media: '(prefers-color-scheme: dark)', color: '#0a0a0a' },
],
};
export const metadata: Metadata = {
metadataBase: new URL('https://your-site.com'),
title: {
default: 'Site Title - Main Keyword',
template: '%s | Site Name',
},
// ~150-160 chars is a guideline, not a limit โ Google truncates per device/query
description: 'Compelling description with target keywords',
// No `keywords` field: Google ignores the keywords meta tag entirely
openGraph: {
type: 'website',
locale: 'en_US',
url: 'https://your-site.com',
siteName: 'Site Name',
title: 'Site Title',
description: 'Description for social sharing',
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: 'Site preview' }],
},
twitter: {
card: 'summary_large_image',
title: 'Site Title',
description: 'Description for Twitter',
images: ['/og-image.png'],
},
// Set alternates.canonical per page; a root '/' would be inherited by children.
robots: {
index: true,
follow: true,
},
};
import type { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = 'https://your-site.com';
const posts = await getPosts(); // your CMS/DB
return [
{
url: baseUrl,
images: [`${baseUrl}/og-image.png`], // Image Sitemap entry
},
{ url: `${baseUrl}/about` },
...posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: post.updatedAt, // real content timestamp
})),
];
}
lastModified must reflect the content's actual last change (CMS updatedAt, file mtime, git commit date) โ Google uses lastmod only when it's consistently accurate, and new Date() on every build marks everything "just changed", which teaches Google to ignore it. Skip changeFrequency and priority: Google ignores both.
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
const baseUrl = 'https://your-site.com';
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/admin/'],
// Do NOT disallow /_next/ โ crawlers need render-critical CSS/JS
// Do NOT add bot-specific rules (Googlebot, Bingbot) unless overriding wildcard โ
// and if you do, repeat all disallows: named groups don't inherit `*` rules
// (RFC 9309 ยง2.2.1; Google never merges a specific group with `*`)
},
],
sitemap: `${baseUrl}/sitemap.xml`,
};
}
hostwas omitted intentionally โ it's a non-standard directive Google ignores. Use canonical URLs / 301s to declare the preferred host instead. See references/sitemap-robots.md.
Same MetadataRoute family as sitemap/robots, placed at the root of app/. Not an SEO requirement โ a PWA-completeness nicety with no ranking effect; skip it unless the site is (or may become) a PWA. Full example in references/metadata-api.md.
Three ways to set social images โ prefer the file conventions over hand-syncing URLs in the metadata object:
openGraph.images / twitter.images examples above) โ fine for externally hosted images.opengraph-image.(png|jpg|gif) and/or twitter-image.* into a route segment (app/opengraph-image.png for the root, app/blog/opengraph-image.png for /blog). Next.js auto-emits og:image/twitter:image + :type/:width/:height. A deeper, more specific image overrides one above it. Add alt text with a sibling opengraph-image.alt.txt. Build fails if the file exceeds 8 MB (OG) / 5 MB (Twitter).ImageResponse (per-page/per-post images): an opengraph-image.tsx in the route segment exporting alt, size, contentType and a default Image({ params }) (params is a Promise in v16) that returns new ImageResponse(<jsx/>, { ...size }). Renders via Satori โ flexbox only, no display: grid; statically optimized at build time unless it reads request-time data. Full example, fonts, generateImageMetadata and the favicon/icon.tsx/apple-icon conventions: references/metadata-api.md.With cacheComponents: true, use "use cache" for data/components that can be shared and whose freshness requirements permit caching. Static content needs no extra cache directive merely for SEO:
// app/(home)/sections/hero-section.tsx
import { cacheLife, cacheTag } from "next/cache";
export async function HeroSection() {
"use cache";
cacheLife("hours"); // SEO content that changes a few times/day; see profiles below
cacheTag("hero"); // Invalidate via updateTag("hero") in a Server Action
const data = await fetchData();
return <div>{/* SEO-visible content */}</div>;
}
Choose cacheLife from the product's freshness requirements and the installed
Next.js documentation. Do not infer a cache lifetime from the page category;
marketing and legal pages also need timely publication and invalidation.
Key rules:
"use cache" must be the first statement in the function body (or at the top of the file for file-level caching)cookies()/headers()/runtime searchParams inside a plain "use cache" scope. Keep public content separate from personalized data; do not accidentally cache private information for all users. Check the installed docs before using experimental private caching.updateTag("hero") inside a Server Action (read-your-writes; it throws outside one), or revalidateTag("hero", "max") from a Route Handler / webhook (pass the profile โ the one-argument form is legacy behaviour) โ prefer these over export const revalidatenext build, the served content and publish-time invalidation. Legacy route options such as revalidate are disabled with Cache Components; without it, follow the installed version's supported cache model.| Strategy | Use When | SEO Impact |
|---|---|---|
| "use cache" | Shared data with a defined refresh policy | Can include content in the prerendered shell |
| SSG (Static) | Content known at build time | Content available in HTML; plan updates |
| SSR | Content needed at request time | Content available in the response; measure latency |
| CSR | Interactive or authenticated features | Avoid relying on browser-only fetching for critical public content |
These are rendering trade-offs, not ranking tiers. A Client Component can still
be server-prerendered; "use client" does not mean its content is absent from HTML.
| Metric | Target | Impact |
|---|---|---|
| LCP (Largest Contentful Paint) | < 2.5s | Loading speed |
| INP (Interaction to Next Paint) | < 200ms | Interactivity |
| CLS (Cumulative Layout Shift) | < 0.1 | Visual stability |
generateMetadata, OG/icon files, ImageResponse, the manifest, or when streaming metadata / htmlLimitedBots is in playgenerateSitemaps, image/video sitemaps, multi-group robots rules, static robots.txt/sitemap.xml files@graph patternalternates.canonical when duplicate/parameterized URLs are a risk; it's a hint, not a requirement โ Google may pick its own canonical/_next/ in robots.txt - Crawlers need render-critical CSS/JS; never disallow /_next/favicon.ico/icon.*/opengraph-image.* file conventions; they auto-emit tags and override the metadata objectGPTBot disallow: / blocks training but leaves you in AI search; don't accidentally block citation bots (OAI-SearchBot, PerplexityBot). See references/ai-search.mdkeywords meta tag for Google - Google ignores it entirely (no indexing or ranking effect); it's noise, not a signal* rules - Per RFC 9309 ยง2.2.1 the * group applies only when no group matches, and Google never merges a specific group with *. A { userAgent: 'OAI-SearchBot', allow: '/' } group drops the wildcard's /api///admin/ disallows โ repeat them in every named groupโ in the build output) can serve perfect SEO HTML while none of its <Suspense> boundaries hydrate on a direct load. Load the route directly in a browser and interact with it; the observation and the check are in references/troubleshooting.md.export const metadata: Metadata = {
robots: {
index: false,
follow: true,
},
};
Keep the page crawlable for noindex to be seen. Robots disallow is not an
index-removal or access-control mechanism. Preview protection and noindex are
separate concerns; see references/sitemap-robots.md.
type Props = { params: Promise<{ id: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params; // params is a Promise in current Next.js
const product = await getProduct(id);
return {
title: product.name,
description: product.description,
};
}
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
return {
alternates: {
canonical: `/products/${slug}`,
},
};
}