Next.js SEO Optimization
Comprehensive SEO guide for Next.js App Router applications.
Quick SEO Audit
Run this checklist for any Next.js project:
- Check robots.txt:
curl https://your-site.com/robots.txt - Check sitemap:
curl https://your-site.com/sitemap.xml - Check metadata: View page source, search for
<title>and<meta name="description"> - Check JSON-LD: View page source, search for
application/ld+json - Check Core Web Vitals: Use PageSpeed Insights (pagespeed.web.dev) and the Search Console CWV report for field data — Lighthouse is lab-only and can't measure INP
Essential Files
app/layout.tsx - Root Metadata
typescriptimport 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'], }, alternates: { canonical: '/', }, robots: { index: true, follow: true, }, };
app/sitemap.ts - Dynamic Sitemap
typescriptimport 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.
app/robots.ts - Robots Configuration
typescriptimport 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.
app/manifest.ts - Web App Manifest
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.
OG / Twitter Images
Three ways to set social images — prefer the file conventions over hand-syncing URLs in the metadata object:
- External URL in metadata (the
openGraph.images/twitter.imagesexamples above) — fine for externally hosted images. - Static file convention (recommended default): drop
opengraph-image.(png|jpg|gif)and/ortwitter-image.*into a route segment (app/opengraph-image.pngfor the root,app/blog/opengraph-image.pngfor/blog). Next.js auto-emitsog:image/twitter:image+:type/:width/:height. A deeper, more specific image overrides one above it. Add alt text with a siblingopengraph-image.alt.txt. Build fails if the file exceeds 8 MB (OG) / 5 MB (Twitter). - Dynamic generation with
ImageResponse(per-page/per-post images): anopengraph-image.tsxin the route segment exportingalt,size,contentTypeand a defaultImage({ params })(params is a Promise in v16) that returnsnew ImageResponse(<jsx/>, { ...size }). Renders via Satori — flexbox only, nodisplay: grid; statically optimized at build time unless it reads request-time data. Full example, fonts,generateImageMetadataand the favicon/icon.tsx/apple-iconconventions: references/metadata-api.md.
Key Principles
Cache Components & SEO
With cacheComponents: true in next.config.ts (the v16 top-level flag that unifies the old experimental.dynamicIO/ppr/useCache), use the "use cache" directive for SEO-critical server components:
typescript// 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>; }
Built-in cacheLife profiles (stale / revalidate / expire): seconds (30s/1s/1m), minutes (5m/1m/1h), hours (5m/1h/1d), days (5m/1d/1w), weeks (5m/1w/30d), max (5m/30d/1y), and the implicit default (5m/15m/never). For SEO pages pick by how often content changes — days for blog/docs, max for legal/marketing. (minutes revalidates every 1 min — too aggressive for most SEO content.)
Key rules:
"use cache"must be the first statement in the function body (or at the top of the file for file-level caching)- No
cookies()/headers()/searchParamsinside a plain"use cache"scope — good for SEO, since indexable content should be request-agnostic. ("use cache: private"does allow them, but is never prerendered, so it never lands in the static SEO shell.) - Invalidate with
updateTag("hero")inside a Server Action (read-your-writes; it throws outside one), orrevalidateTag("hero", "max")from a Route Handler / webhook (pass the profile — the one-argument form is legacy behaviour) — prefer these overexport const revalidate - Very short cache profiles can change what Next.js includes in the prerendered shell. Do not infer that behavior from
revalidatealone: choose a profile from the documented freshness requirements and verify the installed Next.js version'snext buildoutput. Preferhours/days/maxfor SEO-critical content unless the product genuinely needs fresher data - Sitemaps and metadata are static by default — only add
"use cache"(+cacheTag) if they fetch CMS/dynamic data you want to invalidate on publish
Rendering Strategy for SEO
| Strategy | Use When | SEO Impact |
|---|---|---|
| "use cache" | Server components with periodic data | Best - cached HTML, fast TTFB |
| SSG (Static) | Content rarely changes | Best - pre-rendered HTML |
| SSR | Dynamic content per request | Great - server-rendered |
| CSR | Dashboards, authenticated areas | Poor - avoid for SEO pages |
Core Web Vitals Targets
| 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 |
- Measured on field data, not lab. Google ranks on the 75th percentile of real users (Chrome UX Report, 28-day rolling window, mobile/desktop separate). A URL group passes only when ≥75% of visits hit "Good" on all three. Use PageSpeed Insights and the Search Console CWV report for the real signal — Lighthouse is lab-only and cannot measure INP.
- INP replaced FID as a Core Web Vital on 2024-03-12; FID is deprecated. INP is the most commonly failed metric — prioritize it.
- Page experience is a tiebreaker, not a standalone ranking system (Google de-emphasized it). Good CWV won't rescue thin content; content relevance and quality come first. Treat CWV as baseline UX hygiene.
- Myths to ignore: 2026 SEO blogs falsely claim "LCP was lowered to 2.0s" and invent an "Engagement Reliability" metric. Neither exists in any Google/web.dev source — the LCP and CLS thresholds are unchanged since 2021, and INP's 200 ms has been fixed since it became a Core Web Vital in 2024.
Ranking Signals Beyond Technical SEO
Metadata + CWV alone don't drive rankings. Keep these in mind (out of scope for this skill, but pointers):
- Helpful content is part of core ranking (since 2024-03), evaluated continuously — not an episodic penalty.
- E-E-A-T (Experience, Expertise, Authoritativeness, Trust): cite real authors/credentials and first-hand experience, especially on YMYL pages.
- Mobile-first indexing is complete (since 2024-07): Google indexes the mobile rendering only. Ensure the mobile view has the same content, metadata, and structured data as desktop; never block mobile resources. (Mostly automatic with Next.js responsive design.)
References
- Metadata API — references/metadata-api.md: read when writing
generateMetadata, OG/icon files,ImageResponse, the manifest, or when streaming metadata /htmlLimitedBotsis in play - Sitemap & Robots — references/sitemap-robots.md: read for
generateSitemaps, image/video sitemaps, multi-group robots rules, staticrobots.txt/sitemap.xmlfiles - JSON-LD Structured Data — references/json-ld.md: read before adding any schema type; has the supported/deprecated rich-result list and the
@graphpattern - AI Search (GEO/AEO) & AI Crawlers — references/ai-search.md: read when deciding robots rules for GPTBot/OAI-SearchBot/ClaudeBot etc., or when asked about llms.txt or AI Overviews
- SEO Audit Checklist — references/checklist.md: read when asked to audit a site end to end
- Troubleshooting — references/troubleshooting.md: read when a page is missing from Google, stuck in "Discovered/Crawled – currently not indexed", or indexes fine but never hydrates
Common Mistakes to Avoid
- Mixing next-seo with Metadata API - Use only Metadata API in App Router
- Missing canonical URLs - Set a self-referencing
alternates.canonicalwhen duplicate/parameterized URLs are a risk; it's a hint, not a requirement — Google may pick its own canonical - Using CSR for SEO pages - Use SSG/SSR for indexable content
- Blocking
/_next/in robots.txt - Crawlers need render-critical CSS/JS; never disallow/_next/ - Missing metadataBase - Required for relative URLs in metadata
- Viewport in metadata - Must be a separate export
- Mixing metadata object and generateMetadata - Use one or the other in the same route segment
- Duplicating icons in metadata + file conventions - Prefer
favicon.ico/icon.*/opengraph-image.*file conventions; they auto-emit tags and override the metadata object - Blanket-blocking AI crawlers -
GPTBot disallow: /blocks training but leaves you in AI search; don't accidentally block citation bots (OAI-SearchBot, PerplexityBot). See references/ai-search.md - Adding the
keywordsmeta tag for Google - Google ignores it entirely (no indexing or ranking effect); it's noise, not a signal - Assuming named robots.txt groups inherit
*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 - Trusting browser view for bot metadata - PPR + streaming metadata has served bots pages with no
<title>/canonical (vercel/next.js #95406 — check its status on your version), and the browser view never shows it. Confirm production HTML with a bot User-Agent:curl -A "Googlebot" https://your-site.com | grep -E '<title>|canonical' - Assuming a route that indexes well also works - A PPR route (
◐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.
Quick Fixes
Add noindex to a page
typescriptexport const metadata: Metadata = { robots: { index: false, follow: false, }, };
Dynamic metadata per page
typescripttype 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, }; }
Canonical for dynamic routes
typescripttype Props = { params: Promise<{ slug: string }> }; export async function generateMetadata({ params }: Props): Promise<Metadata> { const { slug } = await params; return { alternates: { canonical: `/products/${slug}`, }, }; }

