Next.js SEO: A Practical Technical Guide for 2026
Metadata, canonicals, sitemaps, structured data, rendering strategy and Core Web Vitals: the technical SEO work that actually moves rankings on a Next.js App Router site.
Next.js is one of the strongest frameworks for SEO because it ships fully rendered HTML to crawlers, but the framework only removes the rendering problem. Rankings still depend on correct metadata, one canonical URL per page, a sitemap that matches reality, structured data, and fast Core Web Vitals. This guide walks through each of those in the App Router, with code you can copy.
Key takeaways
- Use the Metadata API (
metadata/generateMetadata) and setmetadataBaseonce in the root layout. - Every indexable page needs a self-referencing canonical; locale variants need hreflang alternates.
- Generate
sitemap.xmlandrobots.txtfrom code (app/sitemap.ts,app/robots.ts) so they never drift from your routes. - Prefer static rendering or ISR for content pages; never put indexable content behind client-only fetching.
- LCP, INP and CLS are mostly won with
next/image,next/font, less client JavaScript and reserved layout space.
1. The Metadata API: titles, descriptions and social cards
In the App Router you no longer hand-write <head> tags. Export a metadata object for
static pages, or a generateMetadata function when metadata depends on data. Next.js merges metadata from
the root layout down to the page, so put site-wide defaults in the root layout.
// app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
metadataBase: new URL('https://example.com'),
title: {
default: 'Acme Analytics',
template: '%s | Acme Analytics',
},
description: 'Product analytics for B2B SaaS teams.',
openGraph: { type: 'website', siteName: 'Acme Analytics' },
twitter: { card: 'summary_large_image' },
};
metadataBase matters more than it looks: without it, relative URLs in openGraph.images or
alternates.canonical resolve incorrectly or produce build warnings. The title.template keeps
brand suffixes consistent without repeating them on every page.
Dynamic pages with generateMetadata
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getPost } from '@/lib/posts';
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) return {};
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: `/blog/${slug}` },
openGraph: {
type: 'article',
publishedTime: post.publishedAt,
images: [{ url: post.coverImage, width: 1200, height: 630 }],
},
};
}
export default async function Page({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound(); // real 404 status, not a soft 404
return <Article post={post} />;
}
Two details worth copying. First, getPost is called in both functions; wrap it in React's
cache() (or rely on fetch memoization) so the data source is hit once per request. Second,
notFound() returns a proper 404. A page that renders "Post not found" with status 200 is a soft 404, and
enough of them erode crawl budget and trust.
2. Canonicals and hreflang
Duplicate URLs are the most common technical SEO problem I see on Next.js sites: trailing-slash variants, query strings from filters and UTM tags, preview domains, and the same page reachable under two routes. Each indexable page should declare a self-referencing canonical, and the URL in the canonical must match the URL in your sitemap exactly.
export const metadata: Metadata = {
alternates: {
canonical: '/pricing',
languages: {
'en-US': '/en/pricing',
'de-DE': '/de/pricing',
'x-default': '/en/pricing',
},
},
};
For multilingual sites, hreflang alternates must be reciprocal: the German page lists the English page and vice
versa. Each locale page should canonicalize to itself, not to the English version, or you are telling search engines
to ignore your translations. Also set trailingSlash in next.config deliberately and keep it
consistent; Next.js will redirect the other variant with a 308.
3. sitemap.ts and robots.ts
Generate both files from code. A hand-maintained sitemap drifts within weeks.
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { getAllPosts } from '@/lib/posts';
const BASE = 'https://example.com';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
return [
{ url: `${BASE}/`, lastModified: new Date() },
{ url: `${BASE}/pricing` },
...posts.map((p) => ({
url: `${BASE}/blog/${p.slug}`,
lastModified: new Date(p.updatedAt),
})),
];
}
// app/robots.ts
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
const isProd = process.env.VERCEL_ENV === 'production';
return {
rules: isProd
? [{ userAgent: '*', allow: '/', disallow: ['/api/', '/admin/'] }]
// previews: allow crawling so bots can see the noindex (or put them behind auth)
: [{ userAgent: '*', allow: '/' }],
sitemap: 'https://example.com/sitemap.xml',
};
}
Keep lastModified honest. Setting it to "now" for every URL on every build teaches crawlers to ignore
the field. For sites with tens of thousands of URLs, use generateSitemaps to split into multiple files
(the protocol caps each sitemap at 50,000 URLs).
Do not block preview deployments with disallow: '/' and rely on noindex at the same time: a crawler
that is blocked by robots.txt never fetches the page, so it never sees the noindex, and the URL can still be indexed
from links. The best fix for preview and staging is authentication (for example Vercel Deployment Protection). If
previews must be public, allow crawling as above and send noindex on non-production builds, either with a
robots: { index: false } metadata default or an X-Robots-Tag: noindex response header.
4. Structured data (JSON-LD)
The Metadata API has no field for JSON-LD. Render a script tag in the page component instead. Use types that match
what is actually on the page: Article/BlogPosting, Product,
Organization, BreadcrumbList, FAQPage. Markup that describes content the user
cannot see is a spam signal, not a ranking trick.
export default async function Page({ params }: Props) {
const post = await getPost((await params).slug);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: { '@type': 'Person', name: post.author },
};
return (
<article>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
}}
/>
{/* ... */}
</article>
);
}
The replace call escapes < so a title containing </script> cannot break
out of the tag. If you prefer types, the schema-dts package gives you TypeScript definitions for
schema.org. Validate with Google's Rich Results Test before shipping.
5. Rendering strategy and crawlability
Google can render JavaScript, but rendering is deferred and resource-limited, and many other crawlers (including several AI answer engines) do not execute JavaScript at all. The safe rule: anything you want indexed must be present in the initial HTML response.
| Strategy | How in App Router | Use for | SEO notes |
|---|---|---|---|
| Static (SSG) | Default; generateStaticParams for dynamic routes |
Marketing pages, docs, blog | Fastest TTFB, best LCP |
| ISR | export const revalidate = 3600 or revalidateTag |
Catalogs, listings, CMS content | Static speed with fresh content |
| Dynamic (SSR) | Reading cookies/headers, or dynamic = 'force-dynamic' |
Personalized or real-time pages | Crawlable, but TTFB depends on your backend |
| Client-only | 'use client' + useEffect fetch |
Dashboards behind login | Avoid for indexable content |
A subtle trap: a single cookies() call in a shared layout opts every page under it into dynamic
rendering. Check the build output (the route table marks static and dynamic routes) after any layout change.
Streaming with Suspense is fine for SEO as long as the primary content is not behind a client-side
fetch; streamed server content still ends up in the HTML response.
Use next/link for internal navigation. It renders a real <a href>, which crawlers
follow. Click handlers that call router.push on a div are invisible to them.
6. Core Web Vitals: LCP, INP and CLS
Core Web Vitals are a modest ranking signal, but they correlate strongly with conversions, so they are worth the work either way. Google's "good" thresholds are LCP under 2.5s, INP under 200ms and CLS under 0.1 at the 75th percentile of real users.
LCP: images and fonts
import Image from 'next/image';
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], display: 'swap' });
export function Hero() {
return (
<section className={inter.className}>
<Image
src="/hero.webp"
alt="Dashboard showing weekly retention by cohort"
width={1200}
height={630}
priority // preloads the LCP image
sizes="(max-width: 768px) 100vw, 1200px"
/>
</section>
);
}
- Mark the single above-the-fold hero image as
priority; leave everything else lazy. - Always provide
sizesfor responsive images, or the browser downloads oversized files. next/fontself-hosts fonts and generates fallback metrics, which removes a third-party request and most font-swap layout shift.- Write descriptive
alttext; it helps image search and accessibility at the same time.
INP: ship less client JavaScript
INP measures how quickly the page responds to interactions. In the App Router the biggest lever is keeping
components on the server and pushing 'use client' down to the smallest interactive leaf. A
'use client' at the top of a page file turns its whole subtree into client JavaScript. Lazy-load heavy
widgets with next/dynamic, load analytics and chat scripts with next/script using
strategy="lazyOnload", and break long event handlers up so the main thread can paint.
CLS: reserve space
Give every image and embed explicit dimensions or an aspect ratio, reserve space for cookie banners and ads, and avoid
inserting content above existing content after load. Measure with real-user data (the
useReportWebVitals hook or the Chrome UX Report), not just a single Lighthouse run on a fast laptop.
7. Common Next.js SEO mistakes
- Preview deployments indexed. Staging URLs show up in search because nothing blocks them. Fix with the environment-aware robots and noindex setup above.
- Same title and description everywhere. The root layout default leaks to every page because nobody wrote page-level metadata.
- Canonical points to the homepage. A canonical set in the root layout is inherited by every page, telling Google they are all duplicates of
/. Set canonicals per page. - Soft 404s. Missing records render an empty template with status 200 instead of calling
notFound(). - Content hidden behind client fetches. Product descriptions or reviews loaded in
useEffectare often missing from the indexed version. - Accidental dynamic rendering. One header read in a layout makes the whole site slower to serve.
- Redirect chains after migrations. Map old URLs directly to final URLs in
redirects()with permanent status, one hop each.
A pre-launch checklist
- View source (not DevTools) on key templates and confirm the main content and metadata are in the HTML.
- Check one URL per template for a unique title, description and self-referencing canonical.
- Fetch
/robots.txtand/sitemap.xmlon production and on a preview URL. - Run the Rich Results Test on pages with JSON-LD.
- Submit the sitemap in Search Console and watch the Pages report for "Duplicate without user-selected canonical".
- Confirm the build output shows content routes as static or ISR.
SEO in Next.js overlaps heavily with general architecture decisions, so it pays to make them early. If you are planning a new build, my notes on building scalable web applications and the full-stack developer roadmap cover the surrounding pieces. If you are evaluating someone to do this work, the guide to hiring a React and Next.js developer lists what to ask about rendering strategy. And if you want help auditing or building a site, see my React and Next.js development services.
FAQ
Is Next.js good for SEO?
Yes. Next.js renders HTML on the server or at build time, so crawlers receive complete content without executing JavaScript. You still have to configure metadata, canonicals, sitemaps and performance correctly; the framework makes good SEO possible, not automatic.
Should I use SSG, ISR or SSR for SEO?
All three produce crawlable HTML. Prefer static generation or ISR for content that changes infrequently because it is fastest, and use dynamic rendering only for pages that genuinely need per-request data. Avoid client-only rendering for content you want indexed.
Do I still need a sitemap with Next.js?
Yes for any site with more than a handful of pages or with dynamic routes. An app/sitemap.ts file generates sitemap.xml from your data source, which helps search engines discover new and updated URLs quickly.
How do I add JSON-LD structured data in the App Router?
Render a script tag with type application/ld+json inside the page component and pass JSON.stringify of your schema object, escaping the less-than character. There is no dedicated metadata field for JSON-LD.
Why are my Next.js pages crawled but not indexed?
Common causes are duplicate content without canonicals, thin or near-identical pages, soft 404s that return status 200, accidental noindex tags from staging config, and canonicals that point to a different URL than the one in the sitemap.