Next.js SEO is mostly a matter of configuration. The framework renders pages on the server or at build time by default, so crawlers get complete HTML; your job is to set the title, description, canonical and robots tags per route with the Metadata API, generate a sitemap and robots.txt, answer missing pages with a real 404, and avoid the few patterns that move content back into the browser. This guide covers the App Router in Next.js 15 and 16, with notes for the older Pages Router. It sits under our guide to JavaScript SEO for sites built with frameworks, which explains why server-rendered HTML matters for Google and AI crawlers alike.
Quick answer
- Keep indexable data on the server. Fetch it in Server Components, not in a
useEffectin a Client Component. - Set metadata with
export const metadataorgenerateMetadata, plusmetadataBasein the root layout so relative URLs become absolute. - Give every route a self-referencing canonical with
alternates.canonical, and hreflang withalternates.languagesif you have language versions. - Generate
sitemap.xmlandrobots.txtfromapp/sitemap.tsandapp/robots.ts. - Call
notFound()for missing records and usepermanentRedirect()orredirectsinnext.configfor moved pages. - Know about streaming metadata (Next.js 15.2 and later): readers that do not run JavaScript and are not on Next.js's bot list get the tags at the end of the body.
- Check a route with the free JavaScript SEO check to confirm the raw HTML carries the tags and content.
How Next.js renders pages
In the App Router, every component is a Server Component unless its file starts with 'use client'. Server Components render to HTML on the server, either at build time (static) or per request (dynamic), and Next.js decides which from what the route uses. A page that reads cookies(), headers() or searchParams, or fetches uncached data, is rendered per request; one that does not can be prerendered.
Two points often cause confusion:
- Client Components are still rendered on the server.
'use client'marks a component for hydration in the browser, but its first render happens on the server too. A product description passed as props to a Client Component is in the HTML. - Data fetched in the browser is not. If a Client Component loads its content with
fetchinsideuseEffect, or through a client data library on mount, the server HTML contains only the loading state. The same happens withdynamic(() => import('./Reviews'), { ssr: false }), which leaves the component out of the server render entirely.
So the rule is simple: whatever should rank is fetched on the server and rendered into the HTML. Interactive parts can hydrate on top. Our comparison of server-side and client-side rendering explains the trade-offs behind each mode, and the React SEO guide covers React apps outside Next.js.
Metadata API: titles, descriptions and robots
Next.js writes the head from the metadata object or the generateMetadata function a layout or page exports. Values from nested layouts merge, with the page winning.
// app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
metadataBase: new URL('https://example-shop.hr'),
title: { default: 'Example Shop', template: '%s | Example Shop' },
description: 'Handmade shoes from Zagreb, shipped across the EU.',
};// app/products/[slug]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getProduct } from '@/lib/products';
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const product = await getProduct(slug);
if (!product) notFound();
return {
title: product.name,
description: product.summary,
alternates: { canonical: `/products/${slug}/` },
openGraph: { images: product.image },
};
}With metadataBase set, /products/blue-shoe/ becomes https://example-shop.hr/products/blue-shoe/ in the canonical and Open Graph tags. Without it, a relative path in a URL field causes a build error, so set it once in the root layout rather than repeating the host in every page.
For pages that should stay out of search, set robots: { index: false } in their metadata. That writes <meta name="robots" content="noindex"> into the server HTML, where Google reads it before rendering. Never add noindex from a Client Component: our noindex and nofollow reference explains why a robots tag changed by script is unreliable.
Tip
Use generateMetadata and the page component with the same data function. Next.js deduplicates identical fetch requests during one render, and React's cache() does the same for database calls, so the product is loaded once.
Streaming metadata and bots
Since Next.js 15.2, generateMetadata can be streamed. When it has to wait for data at request time, Next.js sends the page first and, according to the generateMetadata documentation, appends the resulting tags to the <body> once they resolve. Next.js says it has verified that Googlebot, which renders JavaScript and reads the full DOM, interprets them correctly.
For "HTML-limited bots" that cannot run JavaScript, Next.js blocks instead and puts the tags in the <head>. It detects them by User-Agent. At the time of writing, the default list includes Bingbot, Applebot, DuckDuckBot, Yandex, Baidu, Google's special-purpose crawlers, and the preview bots of Facebook, X, LinkedIn, Slack, Discord and WhatsApp. It does not include the AI crawlers such as GPTBot, ClaudeBot or PerplexityBot, which, as the JavaScript SEO guide explains, read only the raw HTML.
If AI assistants matter to you, override the list in next.config.ts with the htmlLimitedBots option. A regular expression that matches everything turns streaming metadata off:
// next.config.ts
import type { NextConfig } from 'next';
const config: NextConfig = {
htmlLimitedBots: /.*/,
};
export default config;That costs some time to first byte on dynamic pages, because the head waits for the data. Statically rendered pages are not affected, since their metadata is resolved at build time.
Canonicals and hreflang
Every indexable route should carry a canonical that points at itself: the clean URL, without tracking parameters, with the same trailing-slash choice as the rest of the site. Set it with alternates.canonical. Keep trailingSlash in next.config consistent with your canonicals, because Next.js redirects the other form automatically.
For language versions, alternates.languages writes the hreflang tags:
alternates: {
canonical: '/de/produkte/blauer-schuh/',
languages: {
en: '/en/products/blue-shoe/',
de: '/de/produkte/blauer-schuh/',
'x-default': '/en/products/blue-shoe/',
},
},Each language version should list the complete set, itself included, and its canonical should point at itself, not at the English page. The guide to hreflang and canonical tags together explains why.
Sitemap and robots.txt
Next.js turns special files in the app folder into routes:
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { getAllProducts } from '@/lib/products';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const products = await getAllProducts();
return products.map((p) => ({
url: `https://example-shop.hr/products/${p.slug}/`,
lastModified: p.updatedAt,
}));
}// app/robots.ts
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/', disallow: '/account/' },
sitemap: 'https://example-shop.hr/sitemap.xml',
};
}A sitemap file may list at most 50,000 URLs. For larger sites, generateSitemaps splits the output into several files. Use real modification dates for lastModified, not the build time, as our note on sitemap lastmod explains. Do not disallow /_next/ in robots.txt: Google needs the scripts and styles there to render the page.
Status codes and redirects
Missing pages. Call notFound() from next/navigation when a record does not exist. It renders your not-found.tsx and adds a noindex meta tag. The status is 404 as long as streaming has not started; if a loading.tsx boundary has already sent part of the page, the status stays 200 and only the noindex protects you. Resolve the record before any Suspense boundary, for example in generateMetadata as above, so missing pages answer a true 404.
Moved pages. For permanent moves, use redirects() in next.config with permanent: true, or permanentRedirect() in a Server Component. Both answer 308, which Google treats like 301. redirect() answers 307, a temporary redirect. As with notFound(), a redirect thrown after streaming has started becomes a client-side redirect through a meta tag, so redirect before the page starts rendering. Our 301 redirects guide covers the choice between codes.
Links, images and performance
- Links.
next/linkrenders a normal<a href>, so Google can follow it. Do not replace it withrouter.push()in anonClickon a<div>. - Images.
next/imagerequiresalt; write real descriptions for content images andalt=""for decorative ones. Load the LCP image eagerly:priorityin Next.js 15, andloading="eager"orfetchPriority="high"in Next.js 16, wherepriorityis deprecated. - Scripts. Load third-party tags with
next/scriptandstrategy="lazyOnload"orafterInteractiveso they do not block rendering. - Headers and caching. Static routes are served from the cache; our guide to headers and caching on static sites and Next.js covers the settings.
Pages Router notes
Older apps in the pages folder render on the server with getServerSideProps or at build time with getStaticProps. Set tags with next/head inside the page. Return { notFound: true } from either data function for a 404, and { redirect: { destination, permanent: true } } for a 308. The same rule applies: data fetched in useEffect is not in the HTML.
How to check a Next.js site
The check fetches the route as raw HTML, then renders it in Chromium, and compares title, description, canonical, robots, headings, links, word count and JSON-LD. On a healthy Next.js page the two columns match. A title missing from the raw column usually means streamed metadata; text missing from it usually means client-side fetching or ssr: false. The full report runs the same comparison:
Also request an unknown URL such as /products/does-not-exist/ with curl -I and confirm it answers 404, and open /sitemap.xml and /robots.txt on production, not only in development.
Common mistakes
- Hard-coding the staging host. Canonicals and
og:imageURLs built from an environment variable point at the preview domain on production. SetmetadataBasefrom the production URL. - Every page canonicalised to the home page because the canonical was set once in the root layout.
- Content loaded in
useEffecton product or article pages. ssr: falseon the main content to silence a hydration warning.noindexleft on from staging, set in the root layout by an environment check that production also matched.- Loading boundaries above missing-record checks, so unknown URLs answer
200.
Questions people ask
Is Next.js good for SEO?
Yes. Next.js renders pages on the server or at build time by default, so crawlers receive complete HTML with the title, text, links and structured data already in place. It also has a Metadata API for head tags and file-based sitemap.ts and robots.ts. SEO problems in Next.js apps usually come from content fetched in the browser, missing canonicals or status codes, not from the framework.
Does Next.js generate a sitemap automatically?
No, but it makes one easy. Add app/sitemap.ts that returns a list of URLs with modification dates, and Next.js serves it at /sitemap.xml. For sites with more than 50,000 URLs, generateSitemaps splits the list into several files. A static sitemap.xml in the app folder works too. Pair it with app/robots.ts so robots.txt points at the sitemap.
Does 'use client' hurt SEO in Next.js?
No. Client Components are rendered on the server first and hydrated in the browser, so their output is in the HTML. What hurts is data a Client Component fetches after it mounts, for example in useEffect, and components loaded with ssr: false: neither is in the server HTML. Fetch indexable content in a Server Component and pass it down as props.