What SEO means in Next.js, and the best practices that matter
SEO in Next.js means making sure every page sends search engines complete HTML, with the right title, description, canonical, status code and links, on the first request. Next.js is well suited to this because it renders on the server by default in the App Router, but it is easy to undo that by fetching content in the browser, forgetting a canonical, returning 200 for missing pages or leaving a preview deployment indexable. This guide explains how rendering affects crawling, shows the Metadata API and the older Head component with working code, covers sitemaps, robots rules, structured data and redirects, and gives the curl checks that prove what Google actually receives.
- What SEO means in a Next.js site
- Rendering choices and how they affect crawling
- The Metadata API: titles, descriptions and canonicals
- Sitemaps, robots, structured data and status codes
- Links, images and speed
- A worked example: a shop that Google could not read
- The fix, step by step
- Edge cases worth knowing
- Keeping a Next.js site search-friendly
- Questions and answers
What SEO means in a Next.js site
Search engines judge the HTML a URL returns, the status code, how fast it arrives and the links inside it. In a Next.js site, the framework decides much of that for you, so Next.js SEO is mostly about using its features in the way that puts the right content into the first response:
- Rendering. Is the main content in the HTML sent by the server, or only built in the browser after JavaScript runs?
- Metadata. Does every route set its own title, description, canonical and social tags?
- Discovery. Do pages link to each other with real
<a href>links, and is there a sitemap and a robots file? - Status codes. Do missing pages return 404 and moved pages a permanent redirect?
- Structured data and speed. Is JSON-LD in the HTML, and does the page pass Core Web Vitals on mobile?
Google can run JavaScript, but rendering happens in a queue after crawling, and many other crawlers, including most AI crawlers, read only the raw HTML. Content that needs the browser to appear is slower to be indexed at best, and invisible to some systems at worst. See JavaScript rendering for crawlers.
Rendering choices and how they affect crawling
| Approach | How it is written | Content in first HTML? | SEO verdict |
|---|---|---|---|
| Static generation | Server Component with no dynamic data, or generateStaticParams | Yes | Best for content pages |
| Incremental regeneration | export const revalidate = 3600 | Yes | Good for catalogues that change |
| Server rendering per request | Dynamic Server Component, or getServerSideProps | Yes | Good, watch server response time |
| Client Component rendered on the server | 'use client' with data passed in as props | Yes | Fine |
| Client-side data fetching | useEffect or a client fetch library | No, only a loading state | Avoid for content you want indexed |
A common mistake is to believe 'use client' hides content from Google. It does not: Client Components are still rendered to HTML on the server. What hides content is fetching the data inside the browser, so the server-sent HTML contains only a spinner. Fetch the data in a Server Component, or in getStaticProps or getServerSideProps in the Pages Router, and pass it down.
How to check what is in the HTML
curl -s https://example.com/products/blue-mug | grep -c 'Blue Mug'
curl -s https://example.com/products/blue-mug | grep -iE '<title>|rel="canonical"|name="description"|name="robots"'
If the product name is not found in the raw HTML, the content is being built in the browser. URL Inspection in Search Console shows the rendered version Google produced, which is useful for comparison.
The Metadata API: titles, descriptions and canonicals
In the App Router, metadata is exported from layout and page files, and Next.js writes the tags into the head. Set the defaults once in the root layout:
// app/layout.tsx
export const metadata = {
metadataBase: new URL('https://example.com'),
title: { default: 'Example Bakery', template: '%s | Example Bakery' },
description: 'Sourdough, pastries and celebration cakes baked daily.',
alternates: { canonical: '/' },
};
metadataBase turns relative URLs into absolute ones, so canonicals and social images do not point at localhost. For dynamic routes, use generateMetadata and give every page its own canonical:
// app/products/[slug]/page.tsx
import { notFound } from 'next/navigation';
export async function generateMetadata({ params }) {
const { slug } = await params;
const product = await getProduct(slug);
if (!product) return {};
return {
title: product.name,
description: product.summary,
alternates: { canonical: `/products/${slug}` },
openGraph: { title: product.name, images: [product.image] },
};
}
export default async function Page({ params }) {
const { slug } = await params;
const product = await getProduct(slug);
if (!product) notFound();
return <ProductView product={product} />;
}
In recent versions params is a promise, hence the await. Data requests made with fetch in both functions are de-duplicated, so the product is not fetched twice. The Next.js meta tags guide covers Open Graph and Twitter fields in more detail.
The SEO component in the Pages Router
In the Pages Router there is no Metadata API. The equivalent is the built-in next/head component, which many people call the Next.js SEO component:
import Head from 'next/head';
<Head>
<title>{`${product.name} | Example Bakery`}</title>
<meta name="description" content={product.summary} />
<link rel="canonical" href={`https://example.com/products/${product.slug}`} />
</Head>
Third-party packages such as next-seo wrap the same tags in a component with props. They work, but in the App Router the built-in Metadata API does the job without an extra dependency.
Sitemaps, robots, structured data and status codes
Next.js generates /sitemap.xml and /robots.txt from special files in the app folder:
// app/sitemap.ts
import type { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const products = await getAllProducts();
return [
{ url: 'https://example.com/', lastModified: new Date() },
...products.map((p) => ({
url: `https://example.com/products/${p.slug}`,
lastModified: p.updatedAt,
})),
];
}
// app/robots.ts
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/', disallow: '/api/' },
sitemap: 'https://example.com/sitemap.xml',
};
}
For structured data, render a JSON-LD script inside the page component, escaping the less-than character as the Next.js documentation recommends:
const jsonLd = { '@context': 'https://schema.org', '@type': 'Product', name: product.name };
<script type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c') }} />
See JSON-LD in Next.js and robots and sitemaps in Next.js.
Status codes and redirects
- Call
notFound()when a record does not exist, so the route returns 404 instead of an empty page with 200. Check custom error pages with the Next.js error pages guide. - Permanent redirects in
next.config.jswithpermanent: truereturn 308, which Google treats the same as 301. - Pick
trailingSlashtrue or false once, and use the same form in canonicals, links and the sitemap.
Links, images and speed
- Use
next/linkfor navigation. It renders a real<a href>, which crawlers follow. Buttons withonClickhandlers that callrouter.pushare not links and pass no discovery. - Use
next/imagewith sizes. Give the hero image the priority setting so it is not lazy-loaded, and set an accuratesizesvalue. See Next.js image optimisation not working. - Keep client JavaScript small. Large client bundles slow interaction on phones. Keep components on the server unless they need state or browser APIs.
- Load third-party scripts with
next/scriptand a deferred strategy, so analytics and chat widgets do not block the first paint.
The Core Web Vitals in Next.js guide covers measurement and the main fixes.
A worked example: a shop that Google could not read
Example Bakery rebuilt its online shop in Next.js. Six weeks after launch, Search Console showed most product pages as Crawled, currently not indexed, and the ones that were indexed all had the same title.
The curl check found the product name missing from the raw HTML. The product page was a Client Component that fetched its data in useEffect, so the server sent a loading skeleton. The root layout set a title but no page set its own, and there was no canonical. Missing products showed a friendly message with status 200.
The fix moved the data fetch into the Server Component page, added generateMetadata with a title, description and canonical per product, called notFound() for missing slugs and added app/sitemap.ts. The same curl command then found the product name, and URL Inspection showed the correct title and canonical. Over the next month the indexed product count rose steadily as Google recrawled.
The fix, step by step
Step 1
Check what the server sends
Pick a page of each template, such as the homepage, a category and a product or article. Fetch each one with curl and search the raw HTML for its main heading and a sentence of body text. Also grep for the title, canonical, description and robots tags. Write down which templates are missing content or tags. This baseline shows where the work is needed and lets you prove later that each fix made it into the HTML.
Step 2
Move content fetching to the server
For any template whose content is missing from the raw HTML, move the data fetch out of useEffect and into a Server Component, or into getStaticProps or getServerSideProps in the Pages Router. Pass the data to Client Components as props if they need interactivity. Rebuild and fetch the page with curl again. Confirm that the heading and body text are now in the HTML before any JavaScript runs.
Step 3
Set default metadata in the root layout
In app/layout.tsx, export a metadata object with metadataBase set to your production address, a default title, a title template that adds your brand, and a default description. In the Pages Router, set the same defaults in a shared layout component using next/head. Build the site and confirm with curl that every page now has a title and that canonical and image URLs are absolute, not relative to localhost.
Step 4
Add page-level metadata and canonicals
In every dynamic route, export generateMetadata and return a unique title, a description and alternates.canonical built from the clean path, without query parameters. For static routes, export a metadata object. Keep the trailing slash consistent with your trailingSlash setting. Confirm by fetching several pages with curl and checking that each canonical matches the address you requested and every title is different.
Step 5
Return correct status codes
Where a route looks up a record, call notFound() when it does not exist, before any loading boundary starts streaming. Add permanent redirects for moved pages in next.config.js with permanent: true, which return 308. Then run curl -I on a made-up product address and on an old address. Confirm the first returns 404 and the second returns 308 with a location header pointing to the new page.
Step 6
Generate the sitemap and robots file
Create app/sitemap.ts that returns every indexable URL with its last modified date, built from the same data source as your pages, and app/robots.ts that allows crawling and points to the sitemap. Leave out redirected, noindexed and parameter URLs. Deploy, open both files in a browser, then submit the sitemap in Search Console. Confirm the status reads Success and the discovered count matches your pages.
Step 7
Add structured data to key templates
Build a JSON-LD object for each template, such as Product, Article or LocalBusiness, from the page's data, and render it in a script tag with type application/ld+json, escaping the less-than character. Test a page of each type in the Rich Results Test and fix any errors. Confirm that the structured data appears in the raw HTML from curl and matches the content visible on the page.
Step 8
Fix links, images and scripts
Replace any navigation built from buttons with router.push with next/link, so pages link with real anchors. Give the hero next/image priority loading and accurate sizes, and load third-party scripts with next/script using a deferred strategy. Then test a typical page in Lighthouse on mobile and compare Largest Contentful Paint and total JavaScript size with your earlier figures. Both should be lower, and the hero image should start loading early in the waterfall.
Step 9
Verify in Search Console
Use URL Inspection on one page of each template, run Test live URL and check the rendered HTML, the detected canonical and that indexing is allowed. Request indexing for the most important pages. Then watch the Pages report over the next few weeks. Falling numbers in reasons such as crawled, currently not indexed show that Google is now reading the content and metadata you fixed.
Edge cases worth knowing
- Preview deployments. Hosts such as Vercel add an
X-Robots-Tag: noindexheader to preview URLs. Check your production domain does not inherit it, and that staging domains on other hosts are protected. - Static export. With
output: 'export', server features such as redirects in the config and dynamic rendering are not available. Handle redirects on the web server or CDN instead. - Middleware rewrites. Rewrites and locale redirects in middleware can send Googlebot somewhere different from users. Test with curl and a crawler user agent, and avoid redirecting by browser language alone. See hreflang in Next.js.
- Streaming and status codes. Once a response has started streaming, the status code cannot change. Make sure checks that decide on a 404 run before any loading boundary, and confirm with
curl -I. - Query parameters. Sort and filter parameters create many addresses for one page. Keep the canonical on the clean path.
Keeping a Next.js site search-friendly
Framework upgrades and new features can change what each route sends. Build the checks into your workflow:
- Add a test that fetches a few key routes from a production build and fails if the title, canonical or main heading is missing from the raw HTML.
- Check
/sitemap.xmland/robots.txtafter every release that changes routing. - Run
curl -Ion a deliberately missing URL after upgrades to confirm it still returns 404. - Crawl the site every few months to catch missing metadata on new routes. The Next.js site audit guide explains what to look for.
1. What is SEO in Next.js?
SEO in Next.js means using the framework's features so each page sends search engines complete HTML on the first request, with its own title, description, canonical, structured data, correct status code and crawlable links. Next.js helps because it renders pages on the server by default in the App Router, and it has built-in tools for metadata, sitemaps and robots files. Most Next.js SEO problems come from fetching content in the browser, missing per-page metadata or wrong status codes, not from the framework itself.
2. Is Next.js good for SEO?
Yes, Next.js is one of the better choices for SEO among JavaScript frameworks, because it can generate pages statically or render them on the server, so the content is in the HTML that crawlers download. It also provides a Metadata API, sitemap and robots file generation, an image component and a link component that outputs real anchors. It can still be used badly. A page that fetches all its content in the browser with useEffect is no better for search than a single-page app.
3. What is the Next.js SEO component?
In the Pages Router, the SEO component is usually next/head, the built-in component that lets you place title, meta and link tags in the head of a page. Some projects use the third-party next-seo package, which wraps the same tags in a component with props for titles, descriptions, canonicals and Open Graph data. In the App Router you do not need a component at all: you export a metadata object or a generateMetadata function from each layout or page, and Next.js writes the tags.
4. Does Google index client-side rendered Next.js pages?
Google can index content that is rendered in the browser, because it runs JavaScript in a second rendering stage, but it is slower and less reliable than server-rendered HTML. Rendering happens in a queue, errors or timeouts in your scripts can leave content out, and many other search engines and AI crawlers do not run JavaScript at all. For pages you want found, render the content on the server with Server Components, static generation or server-side rendering, and keep client-side fetching for personal or interactive data.
5. How do I add a canonical tag in Next.js?
In the App Router, set alternates.canonical in the metadata object or in the value returned by generateMetadata, for example a path such as /products/blue-mug. Define metadataBase in the root layout so Next.js turns the path into an absolute address. In the Pages Router, add a link tag with rel set to canonical inside next/head. Build the canonical from the clean path without tracking or sort parameters, keep the trailing slash consistent, and check the output with curl or URL Inspection.
6. How do I create a sitemap in Next.js?
In the App Router, add a sitemap.ts file to the app folder that exports a default function returning an array of entries, each with a url and optionally lastModified. Next.js serves it at /sitemap.xml. Build the list from the same data source as your pages so new content appears automatically, and leave out redirected or noindexed addresses. For very large sites, generateSitemaps splits the output into several files. In the Pages Router, generate the file at build time or serve it from an API route or a package.
7. Should I use static generation or server-side rendering for SEO?
Both are good for SEO, because both put the full content in the HTML. Static generation is usually better for pages that change rarely, such as articles and landing pages, because the HTML is ready before anyone asks for it and arrives quickly from a CDN. Server-side rendering suits pages that must be current on every request, such as stock levels or prices. Incremental regeneration with a revalidate interval sits between the two and suits most catalogues. Avoid client-only rendering for indexable content.
The JavaScript checker compares the raw HTML your server sends with the rendered page, so you can see which content, links and tags only appear after JavaScript runs and need moving to the server.
Open JavaScript Fixes โ