Next.js gives you server rendering out of the box, but that alone does not guarantee good rankings. Missing canonicals, client-only content, broken redirects and slow pages still hold many sites back. This Next.js SEO checklist covers the technical work for the App Router, using the framework’s current built-in APIs, so search engines and AI assistants can crawl, understand and trust your pages.
Set up the Metadata API properly
The App Router replaces next/head with the Metadata API. You export a metadata object for static pages, or a generateMetadata function for dynamic ones, from any layout or page file. Next.js renders the tags in the server HTML.
Start in the root layout with metadataBase, which lets every other file use relative URLs, and a title template:
// app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
metadataBase: new URL('https://example.com'),
title: {
default: 'Example Studio',
template: '%s | Example Studio',
},
description: 'Web development and design studio.',
openGraph: { siteName: 'Example Studio', type: 'website' },
};
Then generate page-specific metadata from your data. In current versions of Next.js, params is a Promise and must be awaited:
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
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);
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: `/blog/${slug}/` },
openGraph: { type: 'article', publishedTime: post.date },
};
}
Next.js de-duplicates fetch requests, and you can wrap other data calls in React’s cache, so fetching the post in both generateMetadata and the page does not double the work.
Canonical URLs
Set alternates.canonical on every indexable page. It tells search engines which URL to index when the same content is reachable through query strings, tracking parameters or a trailing-slash variant. Make sure the canonical matches the URL format you actually serve.
Generate sitemap.ts and robots.ts
Special files in the app directory produce /sitemap.xml and /robots.txt from code, so they never go stale.
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { getAllPosts } from '@/lib/posts';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
return [
{ url: 'https://example.com/', lastModified: new Date() },
...posts.map((p) => ({
url: `https://example.com/blog/${p.slug}/`,
lastModified: p.updatedAt,
})),
];
}
// app/robots.ts
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/', disallow: ['/admin/', '/api/'] },
sitemap: 'https://example.com/sitemap.xml',
};
}
Only list canonical, indexable URLs in the sitemap, and keep lastModified honest. For very large sites, generateSitemaps splits the output into multiple files. Remember that robots.txt controls crawling, not indexing; use a noindex robots meta tag (the robots field in metadata) for pages that should stay out of results.
Add JSON-LD structured data in Server Components
Structured data helps search engines understand what a page is about and can make it eligible for rich results. In the App Router, render it as a script tag in a Server Component, so it is in the initial HTML:
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
datePublished: post.date,
author: { '@type': 'Person', name: post.author },
};
return (
<article>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, '\u003c'),
}}
/>
<h1>{post.title}</h1>
</article>
);
}
The replace call escapes < characters so content cannot break out of the script tag. Use types that match the page, such as Organization, Product, BreadcrumbList or FAQPage, and only describe content that is visible on the page. Validate with Google’s Rich Results Test.
Create Open Graph images with ImageResponse
Add an opengraph-image.tsx file to a route segment and Next.js generates the image and the matching meta tags automatically.
// 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';
export default async function Image({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return new ImageResponse(
<div style={{ display: 'flex', fontSize: 64, padding: 80 }}>
{post.title}
</div>,
size
);
}
ImageResponse supports a subset of CSS based on flexbox. A static opengraph-image.png file in the folder also works when you do not need per-page images.
Choose rendering strategies that crawlers can read
Server Components render on the server by default, and statically rendered pages are the easiest for crawlers and the fastest for users. For dynamic routes, generateStaticParams pre-renders known pages at build time, and export const revalidate = 3600 refreshes them on a schedule.
Do not hide important content behind client-only rendering
A common misunderstanding is that 'use client' hurts SEO. Client Components are still rendered to HTML on the server. The real problem is content that only appears after a useEffect fetch, or components loaded with dynamic(..., { ssr: false }). Google can render JavaScript, but rendering may be delayed, and many other crawlers, including several AI crawlers, may not run JavaScript at all.
- Fetch primary content in Server Components.
- Keep headings, body text, prices and internal links in the server HTML.
- Use real
<a href>links (next/linkrenders them) instead of click handlers. - Check with “View page source”, not DevTools, to see what crawlers receive first.
Return correct status codes
Call notFound() from next/navigation when content does not exist, so the page returns a 404 rather than a “soft 404” with a 200 status. Use permanentRedirect() for content that has moved.
Use next/image and next/font for Core Web Vitals
Page experience is part of how Google evaluates pages, and the built-in components handle much of it.
- next/image serves responsive, modern-format images and reserves space using
widthandheight(orfillwith a sized parent), which prevents layout shift. Set an accuratesizesprop for responsive images. - Prioritise the LCP image. Mark the main above-the-fold image with
priorityso it is preloaded instead of lazy-loaded. Next.js 16 introducespreloadas the replacement for this prop, so check the docs for your version. - Write descriptive
alttext for meaningful images. - next/font self-hosts fonts and applies fallback size adjustments, which reduces layout shift and removes requests to external font servers.
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], display: 'swap' });
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}
Keep client-side JavaScript lean too. Large client bundles slow down interaction and hurt Interaction to Next Paint (INP).
Handle redirects and trailing slashes consistently
Every page should have one URL. Pick a trailing-slash policy and enforce it with trailingSlash: true (or the default false) in next.config; Next.js then redirects the other form automatically. Make your canonicals, sitemap and internal links use the same format.
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
trailingSlash: true,
async redirects() {
return [
{ source: '/old-blog/:slug', destination: '/blog/:slug/', permanent: true },
];
},
};
export default nextConfig;
permanent: true sends a 308, which search engines treat like a 301. For large or database-driven redirect maps, handle them in middleware (renamed proxy in Next.js 16) instead of a huge config array.
Add hreflang for international sites
The App Router has no built-in i18n routing, so most multilingual sites use a [lang] segment. Tell search engines about the language versions with alternates.languages, which outputs hreflang link tags:
alternates: {
canonical: `/${lang}/pricing/`,
languages: {
'en-US': '/en-US/pricing/',
'de-DE': '/de-DE/pricing/',
'x-default': '/en-US/pricing/',
},
},
Each language version should list all the others and itself, and each should have its own self-referencing canonical. Set the lang attribute on <html> to match.
Prepare for AI search and llms.txt
AI assistants and AI search features increasingly send visitors to websites. The fundamentals are the same as for classic SEO: server-rendered content, clear headings, accurate structured data and pages that answer specific questions directly.
- Decide on AI crawler access in
robots.ts. Crawlers such asGPTBot,OAI-SearchBot,ClaudeBotandPerplexityBotcan be allowed or blocked individually.Google-Extendedcontrols use of your content for Google’s AI models, not inclusion in Google Search. - Consider an llms.txt file. It is a proposed convention: a Markdown file at
/llms.txtthat summarises your site and links to key pages. It is not an official standard and search engines have not committed to using it, so treat it as a cheap extra, not a ranking factor. Place it inpublic/or serve it from a route handler atapp/llms.txt/route.ts.
Measure in Search Console
Technical SEO is not finished until you check how Google actually sees the site.
- Submit
sitemap.xmlin Google Search Console and watch for errors. - Use URL Inspection to view the rendered HTML Google gets and request indexing for new pages.
- Review the Page indexing report for “Duplicate without user-selected canonical”, soft 404s and redirect issues.
- Track the Core Web Vitals report, which uses real Chrome user data. Next.js’s
useReportWebVitalshook can send the same metrics to your own analytics.
Key takeaways
- Set
metadataBase, a title template and per-pagegenerateMetadata. - Add a canonical URL to every indexable page.
- Generate
sitemap.tsandrobots.tsfrom real data. - Render JSON-LD in Server Components and validate it.
- Use
opengraph-imagefiles for share images. - Keep primary content and links in the server HTML.
- Return real 404s and permanent redirects.
- Use next/image (with priority on the LCP image) and next/font.
- Enforce one trailing-slash policy everywhere.
- Add hreflang via
alternates.languagesfor multilingual sites. - Make a deliberate decision about AI crawlers and llms.txt.
- Monitor indexing and Core Web Vitals in Search Console.
Frequently asked questions
Is Next.js good for SEO?
Yes. Next.js renders pages on the server by default and includes built-in tools for metadata, sitemaps, images and fonts. You still need to configure canonicals, structured data and redirects correctly.
Do Client Components hurt SEO in Next.js?
Not by themselves, because Client Components are still rendered to HTML on the server. SEO problems come from content that loads only in the browser, such as data fetched in useEffect or components with server rendering disabled.
How do I add a canonical URL in the Next.js App Router?
Set metadataBase in the root layout, then return alternates: { canonical: '/your-path/' } from the page’s metadata export or generateMetadata function. Next.js renders the canonical link tag in the server HTML.
Does llms.txt improve rankings?
There is no evidence that it affects search rankings, and it is not an official standard. It is a simple, low-cost file that may help some AI tools find your key content.
If your site runs on WordPress rather than Next.js, my WordPress Core Web Vitals checklist covers the performance side. If you want a Next.js site audited or built with these foundations in place, get in touch.