Next.js SEO: The Complete Guide for the App Router in 2026
By Eoin Villarin, 2026-01-08, updated 2026-10-28
The gap between a Next.js page that renders and one that ranks is metadata, canonicals, structured data and internal links. A practical App Router guide for Next.js 16.
How do I set up Next.js SEO correctly with the App Router?
Next.js SEO in the App Router relies on server-rendered HTML plus disciplined metadata: set defaults and a title template in the root layout, use generateMetadata per page, give every indexable page its own canonical, render JSON-LD as a script element, generate the sitemap from the same source as routes, and use descriptive internal links.
Key takeaways
- Never set a canonical in the root layout, because inherited metadata points every non-overriding page at the homepage.
- Google stopped showing FAQ rich results from 7 May 2026, so FAQPage markup no longer produces expandable questions.
- JSON-LD output needs the less-than character escaped, because JSON.stringify does not escape HTML.
- A single sitemap is limited to 50,000 URLs, so very large sites should split sitemaps with generateSitemaps.
- Call notFound() for missing records so the route returns a real 404 instead of a soft 404.
Next.js has genuinely good SEO support built into the App Router, but the defaults only take you so far. The gap between a page that renders and a page that ranks is mostly metadata discipline, canonical handling, structured data and internal linking, and that is where Next.js SEO work succeeds or quietly fails.
This guide covers the metadata API, canonical URLs, JSON-LD, generated sitemaps and robots files, Open Graph images, internal linking for search and AI answers, and the mistakes that cost indexing. Every example targets Next.js 16.4, released in October 2026, where route params are promises and metadata can stream.
For the performance half of ranking, read the Core Web Vitals guide alongside this one.
How does Next.js SEO work in the App Router?
The App Router renders on the server by default, so the HTML a crawler receives already contains your content. On top of that, Next.js provides conventions that map neatly onto technical SEO tasks.
| SEO task | App Router feature | Where it lives |
|---|---|---|
| Titles and descriptions | metadata export or generateMetadata | layout.tsx and page.tsx |
| Canonical and hreflang | alternates in metadata | Any layout or page |
| Structured data | A JSON-LD script element | page.tsx or layout.tsx |
| XML sitemap | MetadataRoute.Sitemap | app/sitemap.ts |
| Crawl rules | MetadataRoute.Robots | app/robots.ts |
| Social previews | ImageResponse from next/og | opengraph-image.tsx in any segment |
How to use the Next.js metadata API
The App Router replaces manual head management with an exported metadata object, or a generateMetadata function when values depend on the route. Both work only in server components, as the generateMetadata reference explains.
Set defaults and a title template in the root layout
A root layout sets the defaults, and each segment overrides only what it needs. A title template means pages supply a fragment rather than repeating the site name, and metadataBase lets every URL field use relative paths.
// app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
metadataBase: new URL('https://www.example.com'),
title: {
template: '%s | Example Studio',
default: 'Example Studio',
},
description: 'Web design and development for growing businesses.',
};
Generate metadata for each post
Since Next.js 15, params arrive as a promise, so await them first. Wrap your data function in React's cache, or use fetch, so the page and generateMetadata share one request instead of querying twice.
// 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) notFound();
return {
title: post.metaTitle,
description: post.metaDescription,
alternates: { canonical: `/blog/${post.slug}` },
openGraph: { type: 'article', publishedTime: post.publishDate },
};
}
Streaming metadata and crawlers
Since Next.js 15.2, generateMetadata can stream: the page shell goes out first and the tags are appended when they resolve. Next.js says this works for crawlers that execute JavaScript, such as Googlebot, while HTML-limited bots like social preview scrapers still get blocking metadata in the head. If you rely on a crawler missing from the default list, add it with the htmlLimitedBots option rather than disabling streaming everywhere.
How to set canonical URLs in Next.js
Duplicate content is rarely deliberate. It arrives through query strings, trailing slashes, pagination and filter combinations, and it splits ranking signals across URLs that should be one page. Google's guide to consolidating duplicate URLs explains how it picks a canonical when you do not.
- Set a canonical on every indexable page. With
metadataBaseset, a relative path inalternates.canonicalbecomes an absolute URL. - Pick one host and one slash convention and permanently redirect everything else. The
trailingSlashoption keeps routing consistent. - Faceted URLs. Filter and sort permutations should usually canonical back to the clean page.
- Pagination. Page two should canonical to itself, not to page one, or you hide its content from the index.
- Never set a canonical in the root layout. Metadata is inherited, so a root-level canonical points every page that does not override it at the homepage.
How to add JSON-LD structured data in Next.js
Structured data does not directly raise rankings, but it helps search engines and AI systems understand what a page is, and it can change how a result looks. Next.js recommends rendering it as a script element in the page, as shown in its JSON-LD guide.
// inside the blog post page component
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
datePublished: post.publishDate,
author: { '@type': 'Person', name: post.author },
image: post.featuredImageUrl,
};
const ldHtml = { __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c') };
return (
<article>
<script type="application/ld+json" dangerouslySetInnerHTML={ldHtml} />
<PostBody post={post} />
</article>
);
The replace call matters. JSON.stringify does not escape HTML, so a title containing a closing script tag could otherwise inject markup into the page.
| Page type | Schema | What it does in 2026 |
|---|---|---|
| Article or post | BlogPosting | Clarifies headline, date, author and image |
| Any deep page | BreadcrumbList | Breadcrumb trail instead of a raw URL |
| Service or profile | Person, ProfessionalService | Entity understanding for your business |
| Product page | Product with offers | Price, availability and review details |
| Q and A content | FAQPage | Describes content, but no rich result since May 2026 |
FAQ rich results are gone
Google stopped showing FAQ rich results from 7 May 2026 and removed the documentation in June, according to its Search Central documentation updates. There is no need to rush to remove existing FAQPage markup that matches visible content, but do not expect expandable questions under your result any more.
Keep it honest
Markup has to describe what is actually on the page. Review stars for reviews a visitor cannot see, or an author who did not write the post, risk a manual action, and recovery costs far more than any click-through gained. Validate with the Rich Results Test before shipping.
How to generate a sitemap and robots.txt
Generate the sitemap from the same source as your routes. A hand-maintained XML file drifts the moment someone publishes without touching it, and a sitemap full of 404s teaches crawlers to trust it less.
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { getAllPosts } from '@/lib/posts';
const BASE = 'https://www.example.com';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
return [
{ url: BASE },
...posts.map((post) => ({
url: `${BASE}/blog/${post.slug}`,
lastModified: post.updatedAt,
})),
];
}
// app/robots.ts
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/', disallow: ['/api/', '/admin/'] },
sitemap: 'https://www.example.com/sitemap.xml',
};
}
- Include only canonical, indexable URLs. No redirects and no noindex pages.
- Use real
lastModifiedvalues. A file that claims everything changed today gets ignored. - Split very large sites with
generateSitemaps, because a single sitemap is limited to 50,000 URLs. In Next.js 16 theideach sitemap receives is a promise. - Reference the sitemap from robots.txt, then submit it in Search Console as well.
How to generate Open Graph images with next/og
Social previews are not a ranking factor, but they are a click-through factor, and a shared link with no image looks broken. An opengraph-image.tsx file in a route segment generates a card per page, and the result is cached by default unless it uses request-time data. Aim for 1200 by 630 pixels.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
import { getPost } from '@/lib/posts';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
type Props = { params: Promise<{ slug: string }> };
const card = { display: 'flex', width: '100%', height: '100%', padding: 80,
alignItems: 'center', fontSize: 64, color: 'white', background: '#0b1020' };
export default async function Image({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
return new ImageResponse(<div style={card}>{post.title}</div>, size);
}
Internal linking and AI search visibility
Internal links are the most under-used lever on most sites, and they cost nothing. They tell crawlers which pages matter and how topics relate, and the same signals help AI systems find the right page to cite.
- Link with descriptive anchors. "Read more" tells a crawler nothing about the destination.
- Link deep, not just to hubs. Pages several clicks from the homepage get crawled less often.
- Cluster related content so each article points to two or three genuinely relevant siblings.
- Use real anchors.
next/linkrenders a proper anchor element. A click handler on a div is not a link.
Internal links are the one ranking input you fully control, and most sites leave them almost entirely to the navigation bar.
What AI Overviews and AI Mode need from your site
Less than the hype suggests. Google's page on AI features and your website says a page only needs to be indexed and eligible for a snippet, with no special schema or AI text files required. For other assistants and agents, see making a website agent-ready and staying visible in Google AI Mode.
Next.js SEO mistakes that cost indexing
- A stray noindex from staging. Shipped to production, it can drop pages from search within days.
- A canonical inherited from the root layout. Every page points at the homepage and falls out of the index.
- Primary content rendered only on the client. If the copy appears only after a client-side fetch, treat indexing as uncertain and fetch it in a server component instead.
- Soft 404s. A missing record that returns a friendly page with a 200 status looks like thin content. Call
notFound()so the route returns a real 404. - Redirect chains. Each hop wastes crawl time, and long chains may not be followed to the end.
- Blocking CSS or JavaScript in robots.txt. The page cannot be rendered as a user sees it, so leave
/_next/crawlable.
Frequently asked questions
Is Next.js good for SEO?
Yes. The App Router renders on the server by default, so crawlers receive complete HTML, and the built-in metadata API, sitemap and robots conventions and Open Graph image generation cover most technical SEO needs without plugins. Rankings still depend on content quality, internal linking and page speed, which the framework enables but does not do for you.
Do I still need the next-seo package?
Usually not. Packages like next-seo became popular in the Pages Router era, when head management was manual. In an App Router project the built-in, typed metadata API handles titles, descriptions, canonicals, Open Graph, Twitter cards and robots directives, so an extra package mostly adds another way to produce duplicate tags.
Does Google index metadata that Next.js streams?
Next.js states that streamed metadata is read correctly by crawlers that execute JavaScript and inspect the full DOM, such as Googlebot. Bots that only read raw HTML, such as many social preview scrapers, get metadata in the head because Next.js blocks for them. Use the URL Inspection tool in Search Console to confirm what Google actually sees.
Should I still add FAQ schema to Next.js pages?
Only when the page genuinely has visible questions and answers, and not for rich results, because Google stopped showing FAQ rich results in May 2026. Time is better spent on BlogPosting, BreadcrumbList, Product and Organization markup, which still influence how results are understood and displayed, and on making the answers themselves clear.
Next steps: audit your Next.js SEO setup
Set metadata per route with sensible inherited defaults, canonical every page, generate sitemaps from your route source, mark up only what is visible and spend real effort on internal links. Then keep the framework current; what's new in Next.js and React in 2026 covers the upgrades worth making.
Want an audit of an existing Next.js site? Get in touch through the contact form, or see how past projects were approached.