How to Build SEO-Friendly Next.js Sites
Next.js is a good foundation for organic search, but it isn’t SEO by default. The framework gives you server rendering, static generation, metadata APIs, and image optimization. It also gives you a dozen ways to accidentally ship a page that renders empty to a crawler, loses its canonical tag, or opts out of static generation because of one line in a layout file.
Most of the Next.js SEO problems I get called in for aren’t exotic. They’re a 'use client' at the top of a page component, a cookies() call in a shared layout that quietly made the entire site dynamic, or metadata defined in a file that never runs on the server. The fixes are usually small. Finding them isn’t.
This is what actually matters, roughly in the order it matters, for an App Router build.
Start with rendering strategy

Every SEO decision downstream depends on how your pages are rendered. Get this wrong and nothing else compensates.
Next.js App Router gives you four practical modes:
Static generation (SSG). The page is rendered to HTML at build time and served as a file. Fastest possible delivery, zero server work per request. This should be your default for anything whose content doesn’t change per visitor: marketing pages, blog posts, documentation, legal pages.
Incremental static regeneration (ISR). Static, but with a revalidation trigger — either a time interval or an on-demand call from your CMS. You get static delivery and fresh content. For any content-driven site with a CMS behind it, this is the mode you want for most routes.
Server-side rendering (SSR). HTML generated per request. Necessary when the page genuinely depends on request context — personalization, authenticated dashboards, real-time data. Slower than static, but still fully crawlable, which is the part that matters.
Client-side rendering. The server sends a shell; JavaScript fills it in. Fine for a logged-in dashboard nobody needs indexed. Bad for anything you want ranking.
The rule I follow: static or ISR unless there’s a specific reason otherwise, and the reason has to be written down. “It was easier during development” is how sites end up fully dynamic without anyone deciding to.
The pitfall that catches most people
In the App Router, dynamic rendering is contagious upward. Calling cookies(), headers(), or searchParams anywhere in a route’s tree opts that route out of static generation. Do it in a shared layout — a common mistake when adding an A/B test, a theme cookie, or a personalization banner — and you’ve made *every page under that layout* dynamic.
Nothing breaks visibly. The site still works. It’s just slower for every visitor and every crawler, and the build output no longer matches what you think you shipped.
Read the build output. next build prints a symbol next to every route indicating whether it’s static or dynamic. If a marketing page is marked dynamic and you didn’t intend that, find out why before you deploy. Making this a CI check that fails on unexpected dynamic routes takes an afternoon and catches the problem permanently.
Similarly, 'use client' at the top of a page component doesn’t stop server rendering — Next.js still pre-renders client components to HTML — but it does mean the whole subtree ships to the browser as JavaScript and hydrates. Keep client components at the leaves: the interactive widget, not the page that contains it.
Metadata with generateMetadata
The App Router handles metadata through two exports: a static metadata object for fixed values, and an async generateMetadata function for anything that depends on data.
For a dynamic route, generateMetadata fetches the same data your page uses and returns titles, descriptions, canonicals, and social tags:
export async function generateMetadata({ params }): Promise<Metadata> {
const post = await getPost(params.slug);
if (!post) return { title: 'Not found' };
return {
title: post.seoTitle ?? post.title,
description: post.seoDescription,
alternates: { canonical: `https://example.com/blog/${post.slug}` },
openGraph: {
title: post.seoTitle ?? post.title,
description: post.seoDescription,
type: 'article',
publishedTime: post.publishedAt,
images: [{ url: post.ogImage, width: 1200, height: 630 }],
},
robots: post.noindex ? { index: false, follow: true } : undefined,
};
}
Four things worth knowing about it:
Fetch calls are deduplicated. Calling getPost() in both generateMetadata and the page component doesn’t double the work, as long as you’re using fetch with Next.js caching or React’s cache(). Don’t contort your code to avoid the second call.
Use metadataBase. Set it once in your root layout. Without it, relative image paths in Open Graph tags resolve incorrectly, and you get social previews with broken images — a problem that only shows up when someone shares a link.
Set canonicals explicitly. Next.js does not generate canonical tags for you. Any site with query parameters, pagination, or filtering needs them, and the absence is invisible until you’re competing with your own duplicate pages.
Handle missing data. If generateMetadata throws on a bad slug, you get an error page instead of a clean 404. Return early, and call notFound() in the page component.
For a title.template in the root layout, remember it only applies to child segments, not the root page itself. Use title.default for that.
Structured data
Structured data doesn’t directly improve rankings, but it’s how you become eligible for rich results — review stars, FAQ accordions, breadcrumb trails, article bylines. On competitive queries that’s real click-through difference.
In Next.js, the practical approach is a JSON-LD script rendered from your server component:
export default async function Page({ params }) {
const post = await getPost(params.slug);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: { '@type': 'Person', name: post.author.name },
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>{/* ... */}</article>
</>
);
}
Which types are worth implementing depends on the site, but a reasonable baseline is Organization and WebSite on the root layout, BreadcrumbList on anything nested, Article on blog posts, FAQPage where you have genuine question-and-answer content, and Product or Service on commercial pages.
Two rules that keep you out of trouble: generate it from the same data that renders the page, so the two never drift apart; and don’t mark up content that isn’t visible on the page. Both are ways sites end up with structured data penalties.
Validate with Google’s Rich Results Test before launch, not after.
Sitemaps and robots.txt
Next.js generates both from code, which is better than a static file because it can’t fall out of sync with what’s actually published.
app/sitemap.ts exports a function returning your URLs:
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
return [
{ url: 'https://example.com', lastModified: new Date(), priority: 1 },
...posts.map((post) => ({
url: `https://example.com/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
})),
];
}
app/robots.ts works the same way, and should reference the sitemap URL.
Points that matter more than they look:
lastModifiedshould be real. Setting it tonew Date()for every URL tells crawlers everything changed today, every day. That’s noise, and it stops being useful signal.- Only include indexable URLs. Pages you’ve marked
noindex, paginated duplicates, and filtered variants don’t belong in a sitemap. A sitemap full of URLs that shouldn’t be indexed undermines the whole point of having one. - Split above 50,000 URLs. Next.js supports sitemap index files via
generateSitemaps. Most sites will never need this. - Skip
priorityunless you have a reason. It’s largely ignored, and inconsistent values are worse than none.
Core Web Vitals
Next.js gives you good defaults here, but the defaults are easy to defeat.
Largest Contentful Paint is usually the hero image or the headline. Two things fix most LCP problems: use next/image with priority on the above-the-fold image so it isn’t lazy-loaded, and use next/font so text renders in the right font immediately instead of swapping after a network round trip. A self-hosted font through next/font also removes a third-party connection from your critical path.
Cumulative Layout Shift comes from content that arrives after first paint and pushes things down. next/image handles this when you give it dimensions — and silently doesn’t when you use fill without a sized container. Cookie banners, ad slots, and embedded widgets are the other usual causes. Reserve the space.
Interaction to Next Paint is a JavaScript problem, and it’s where React sites tend to struggle. The App Router’s real advantage is that server components ship no JavaScript at all. Every component you can keep on the server is bundle you don’t send. The common failure is a 'use client' boundary drawn too high, pulling an entire page tree into the client bundle for the sake of one dropdown.
Measure with real data, not just Lighthouse. Lighthouse runs on your machine under lab conditions; Search Console’s Core Web Vitals report and the Chrome UX Report show what actual visitors experienced. They disagree more often than you’d expect, and field data is the one that counts.
Routing and URL hygiene
Boring, and responsible for more lost traffic than anything above.
Trailing slashes. Pick one convention, set trailingSlash in your config, and make sure your canonicals match. Serving the same page at both /about and /about/ without a redirect is duplicate content you created for free.
Redirects survive migrations. If you’re replacing an existing site, every indexed URL needs to either still exist or 301 to its closest equivalent. Next.js handles these in next.config.js for small sets, or middleware for large ones. This is the single biggest determinant of whether a migration holds its rankings.
404s should return 404. Calling notFound() returns the correct status code. Rendering a “not found” message from a normal page component returns 200, and search engines will happily index a page that says nothing exists.
Pagination and filters. Faceted navigation generates near-infinite URL combinations. Decide deliberately which are indexable, canonical the rest to their base page, and don’t put them in the sitemap.
Where content comes from
If your Next.js front end is pulling from a CMS, the SEO fields need to survive the trip. In a headless WordPress setup, Yoast and RankMath data is available through the API — but only if you read it and pass it to generateMetadata. Plenty of headless builds render beautiful, fast pages with default metadata on every one of them, because nobody wired that connection.
Same for sitemaps. The plugin can’t generate a sitemap for a site it isn’t rendering. Your front end has to, from the content it actually publishes.
Carrying metadata, canonicals, sitemaps, and structured data across the API boundary is a standard part of every headless WordPress development build I do, and it’s the piece most often missing from the ones I get asked to repair.
A pre-launch checklist
Before any Next.js site goes live:
-
next buildoutput reviewed; no unintended dynamic routes - Every page has a unique title and description
-
metadataBaseset; Open Graph images resolve to absolute URLs - Canonical URLs on every indexable page
-
robots.tsandsitemap.tspresent, sitemap contains only indexable URLs - Structured data validated in the Rich Results Test
- Redirect map tested if replacing an existing site
- 404 route returns a real 404 status
- LCP image uses
priority; fonts vianext/font - Staging environment blocked from indexing — and production isn’t
- Search Console verified and sitemap submitted on day one
That last one about staging deserves emphasis. Shipping a production site with noindex inherited from a staging config is a mistake I’ve seen cost a company six weeks of visibility.
Frequently asked questions
Do search engines index client-rendered React?
Google usually will, eventually — it renders JavaScript in a second pass. But “usually, eventually” is a bad foundation for a business that depends on organic traffic, and other crawlers (Bing, social scrapers, AI crawlers) are less reliable. If the content matters, render it on the server.
Is a static Next.js site better for SEO than server-rendered?
Both are fully crawlable, so the difference isn’t visibility — it’s speed, which feeds Core Web Vitals. Static wins there, so use it where you can. Don’t force static onto pages that genuinely need per-request data; SSR is not an SEO problem.
Does Next.js handle canonical tags automatically?
No. You set them in generateMetadata via alternates.canonical. This is one of the most common omissions I find in existing builds.
How do I stop staging being indexed?
Return noindex from robots.ts based on an environment variable, and put HTTP auth in front of the preview environment. Relying on obscurity doesn’t work — preview URLs get shared, linked, and crawled.
Should I use next-sitemap or the built-in sitemap?
The built-in sitemap.ts is enough for most sites and one fewer dependency. next-sitemap is worth it when you need automatic splitting across very large URL sets or complex per-section rules.
Will migrating to Next.js hurt my rankings?
It shouldn’t, if the redirect map is complete and metadata carries over. When rankings drop after a migration, the cause is almost always URLs that quietly stopped existing, or metadata that didn’t get ported. The architecture isn’t the risk; the migration discipline is.
Get a Next.js SEO review
If you have a Next.js site that isn’t performing in search, send me the URL. I’ll look at rendering strategy, metadata coverage, structured data, sitemap accuracy, and Core Web Vitals field data, and tell you what’s actually holding it back.
If you’re planning a build, it’s cheaper to get this right the first time — that’s part of every Next.js development project I take on. And if you’re still choosing between frameworks, React vs Next.js for marketing sites covers that decision directly.