Next.js ships with every tool you need to build a well-ranking site. The problem is that most of those tools are opt-in, and the defaults lean toward the developer experience rather than the crawler experience. A project that reaches for "use client" liberally, skips the Metadata API, and never generates a sitemap ends up no better than a plain React SPA from Google’s perspective.
This guide covers the decisions that actually move the needle: picking the right rendering mode, wiring up per-page metadata, generating a sitemap and robots file in code, adding JSON-LD schema, and avoiding the handful of mistakes that quietly kill Next.js SEO.
Rendering modes and their SEO impact
The rendering mode you choose determines what HTML lands in the browser on the first request. That first HTML is what Googlebot indexes, and it is what AI crawlers read.
Static Site Generation (SSG) pre-renders pages at build time. The HTML is complete before any crawler arrives. This is the best outcome for SEO: zero rendering work required, fast Time to First Byte, and every word of content in the initial response. Use it for pages whose content does not change per-request (blog posts, docs, landing pages).
Server-Side Rendering (SSR) generates HTML on each request. Crawlers still receive complete HTML; they just wait for the server to render it rather than picking up a pre-built file. Use it for pages that need live data (dashboards, personalised content, real-time pricing).
Incremental Static Regeneration (ISR) is SSG with a revalidation window. Pages are pre-built and served statically, then quietly regenerated in the background after a configurable interval. Good for content that changes occasionally (news articles, product pages with inventory counts).
Fully client-side routes are the trap. Any page that ships an empty <div id="__next"></div> and fills content after JavaScript executes is invisible until the crawler runs JavaScript, and many do not. Google has improved its JS rendering, but it is slower and less complete than indexing static HTML. AI crawlers (the ones that feed ChatGPT, Perplexity, and AI Overviews) often skip JavaScript execution entirely, leaving those pages blank in their indexes.
The rendering decision in practice
| Rendering mode | SEO signal | When to use |
|---|---|---|
| SSG | Best | Marketing pages, blog, docs |
| ISR | Strong | News, product catalogue, listings |
| SSR | Good | Personalised or real-time pages |
| Client-side only | Poor | Avoid for indexable content |
The App Router defaults toward server components, which is the right bias. The issue is that “use client” at a layout level promotes everything below it to the client. Keep “use client” at the leaf component level, not the layout or page level. More on this below.
The App Router Metadata API
Next.js 13+ introduced a dedicated Metadata API in the App Router. It replaces the older <Head> pattern and handles deduplication, inheritance, and Open Graph automatically.
For static pages, export a metadata object from the page.tsx file:
// app/blog/nextjs-seo-guide/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Next.js SEO: A Practical Configuration Guide",
description:
"How to configure rendering modes, metadata, sitemaps, and schema in Next.js for better search rankings.",
alternates: {
canonical: "https://example.com/blog/nextjs-seo-guide/",
},
openGraph: {
title: "Next.js SEO: A Practical Configuration Guide",
description:
"How to configure rendering modes, metadata, sitemaps, and schema in Next.js.",
url: "https://example.com/blog/nextjs-seo-guide/",
type: "article",
},
};
export default function Page() {
return <article>{/* content */}</article>;
}
For dynamic pages, use generateMetadata instead. It receives the same params the page component receives and can fetch data:
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
type Props = {
params: { slug: string };
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await fetchPost(params.slug);
return {
title: post.title,
description: post.excerpt,
alternates: {
canonical: `https://example.com/blog/${params.slug}/`,
},
};
}
Two common mistakes here. First, using a single default title in app/layout.tsx and never overriding it at the page level. Every page on the site ends up with the same title tag, which is one of the clearest signals to Google that a site has not been configured for search. Second, omitting the canonical field. Next.js does not inject a canonical tag automatically; you have to provide it.
Generating sitemap.ts and robots.ts
Next.js supports generating both files in code as TypeScript modules in the app/ directory. This is preferable to maintaining static XML files because the sitemap stays in sync with your content automatically.
A minimal app/sitemap.ts:
import type { MetadataRoute } from "next";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await fetchAllPosts(); // your data fetch
const postEntries: MetadataRoute.Sitemap = posts.map((post) => ({
url: `https://example.com/blog/${post.slug}/`,
lastModified: post.updatedAt,
changeFrequency: "weekly",
priority: 0.7,
}));
return [
{
url: "https://example.com/",
lastModified: new Date(),
changeFrequency: "daily",
priority: 1,
},
...postEntries,
];
}
Next.js serves this at /sitemap.xml automatically. Submit that URL in Google Search Console.
For app/robots.ts, a simple configuration:
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/api/", "/admin/"],
},
sitemap: "https://example.com/sitemap.xml",
};
}
Adding JSON-LD structured data
Schema markup helps Google (and AI systems) understand what a page is about: article authorship, product details, breadcrumbs, FAQs. In the App Router, inject it directly in the page component as a script tag. No third-party library required.
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: Props) {
const post = await fetchPost(params.slug);
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
description: post.excerpt,
author: {
"@type": "Person",
name: post.author,
},
datePublished: post.publishedAt,
dateModified: post.updatedAt,
url: `https://example.com/blog/${params.slug}/`,
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>{/* content */}</article>
</>
);
}
Because this runs in a server component, the script tag is present in the initial HTML response. Crawlers do not need to execute JavaScript to find it. For a deeper look at which schema types have the most impact, see the schema markup guide for AI search.
Server vs client components: the “use client” problem
The App Router defaults to server components. Server components render to HTML on the server, so their output is present in the initial response. Client components render in the browser, so their content is absent until JavaScript runs.
The SEO risk comes from placing “use client” too high in the component tree. A common pattern: a developer adds interactivity to a sidebar, marks the sidebar component as a client component, and then discovers that the layout file imported the sidebar, so the entire layout is now client-side. All the content inside that layout is now client-rendered.
Keep “use client” at the smallest possible scope. Interactive elements (dropdowns, carousels, forms) should be client components. Page content (headings, body text, internal links) should remain in server components. If you need to mix them, pass server-rendered content as children or props into a client component wrapper rather than wrapping content in the client component itself.
This also affects page speed. Server components avoid shipping their dependencies to the browser, which reduces bundle size. Smaller bundles mean faster Largest Contentful Paint, which is a ranking signal in Google’s Core Web Vitals.
For more on how this class of problem affects React apps broadly, see React SEO and the JavaScript SEO overview.
AI crawlers and server-rendered HTML
AI systems like ChatGPT, Perplexity, and Google’s AI Overviews build their knowledge from crawled content. Most AI crawlers do not execute JavaScript, or execute it inconsistently. A Next.js site that relies on client-side rendering for its primary content is essentially invisible to these systems.
Server-rendered HTML solves this directly. When a page renders on the server, the full text content is in the HTTP response body. Any crawler, regardless of its JavaScript capabilities, can read it. This matters because AI citation is becoming a meaningful traffic source, and it works exactly like traditional crawling: if the crawler cannot read your content, it cannot cite you.
The through-line is the same as for Google: complete HTML in the initial response, a sitemap that lists every indexable URL, and structured data that gives the crawler context about what it is reading. Tools like Fokal can track whether your site is being cited in AI answers over time, so you can see whether configuration changes are working.
If you are shipping a Next.js site built with an AI tool (such as v0), double-check the rendering output. These tools often scaffold interactive components with “use client” by default, and the resulting page may be more client-rendered than it appears.
Common mistakes
One default title across the whole site. Set a title.template in app/layout.tsx and override title on every page. A template like "%s | Your Brand" propagates brand consistently while keeping each page’s title unique.
No sitemap. If Google cannot find your URLs, it cannot index them. Generate sitemap.ts as described above and submit it in Search Console. Check the submitted sitemap report periodically for excluded URLs.
Missing canonical tags. If your site has URL variants (trailing slash vs none, www vs non-www, query parameters), duplicate content dilutes ranking signals. Always set alternates.canonical in the metadata export pointing to the preferred URL.
Skipping Open Graph tags. OG tags are not just for social sharing. Perplexity and other AI summaries use the og:description field when generating snippets. Set them on every page.
Importing server data into client components. If a client component fetches its own data, that fetch happens in the browser and the data is absent from the initial HTML. Move the fetch to the parent server component and pass the data as props.
What to do next
Next.js gives you a production-ready SEO foundation if you use it deliberately. The checklist below covers the decisions that matter most.
- Confirm every indexable page uses SSG, ISR, or SSR (not client-side only)
- Export a
metadataobject orgenerateMetadatafrom everypage.tsx - Set
alternates.canonicalon every page - Generate
app/sitemap.tsand submit the URL in Google Search Console - Add
app/robots.tswithdisallowrules for non-indexable paths - Add JSON-LD schema to high-value pages (articles, products, FAQs)
- Audit “use client” usage and push it to leaf components
- Set Open Graph tags on every page
- Check Core Web Vitals in Search Console (LCP, CLS, INP)
- Verify AI crawler access by fetching key pages without JavaScript
For a broader view of how JavaScript frameworks affect search and AI citation, start with the platform SEO overview.