On this page
- Rendering decides what crawlers see
- The Next.js SEO baseline in the root layout
- Per-page metadata with generateMetadata
- Canonical URLs
- Open Graph and Twitter cards
- Social images and icons with file conventions
- Sitemaps and robots.txt with MetadataRoute
- Structured data with JSON-LD
- Keeping thin pages out of the index
- Internal links and descriptive anchor text
- One H1 and a clear heading outline
- Pre-launch checklist
- Key takeaways
Most Next.js SEO problems are not exotic. Content that appears only after a client-side fetch, a canonical tag inherited from the wrong layout, or a sitemap full of URLs you never meant to index can quietly undo good writing. This guide covers the technical layer in the Next.js 16 App Router, in TypeScript: rendering, the Metadata API, canonical URLs, file conventions, sitemaps, structured data, and a pre-launch checklist.
Rendering decides what crawlers see
Google crawls the HTML, queues the page for rendering in a headless Chromium, then indexes the result. Rendering can lag behind crawling, and Google's JavaScript SEO basics still recommends server-side rendering or pre-rendering because not every bot runs JavaScript.
Link-preview crawlers are the clearest case: the Next.js docs treat Twitterbot and facebookexternalhit as HTML-limited bots that never execute your scripts. If your title and body exist only after a useEffect fetch, they see an empty shell.
In the App Router, pages and layouts are Server Components, so data they fetch lands in the server HTML. Fetch indexable content there and keep Client Components for interactivity.
Streaming adds two details. Next.js may stream metadata for dynamically rendered pages, but for HTML-limited bots it blocks and puts metadata in the <head>. And once streaming starts, the status is locked at 200, so a notFound() inside a Suspense boundary gets a noindex tag instead. For a real 404, confirm the resource exists before anything streams.
Rendering strategy also shapes load performance, which I cover in Core Web Vitals for React and Next.js.
The Next.js SEO baseline in the root layout
Start with site-wide defaults. I keep the site URL in one module so metadata, the sitemap, robots.txt and JSON-LD always agree.
// lib/site.ts
export const siteUrl = 'https://example.com'
export const siteName = 'Example'// app/layout.tsx
import type { Metadata } from 'next'
import { siteName, siteUrl } from '@/lib/site'
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
title: { template: `%s | ${siteName}`, default: siteName },
description: 'Guides on building fast, crawlable web applications.',
openGraph: { siteName, type: 'website', locale: 'en_US' },
twitter: { card: 'summary_large_image' },
}
// RootLayout component below as usualKey behaviors:
metadataBaselets URL-based fields such asalternates.canonicaluse relative paths. Without it, a relative URL there fails the build.title.templateapplies to child segments, not the one that defines it, and requires atitle.default. A page withtitle: 'About'rendersAbout | Example;title.absoluteopts out.- Merging is shallow. A page that sets
openGraphreplaces the layout's entireopenGraphobject, so repeat shared values likesiteName. - Skip
keywords. Next.js will render the tag, but Google does not use meta keywords.
Metadata exports only work in Server Components, so keep page.tsx on the server and move client state into an imported Client Component.
Per-page metadata with generateMetadata
params became a Promise in Next.js 15, and Next.js 16 removed the synchronous fallback, so always await it. Wrap data lookups in React's cache so generateMetadata and the page share one call per request; fetch is memoized automatically, but database or filesystem reads are not.
// lib/posts.ts
import { cache } from 'react'
import { readAllPosts, readPost } from './source' // your data source
export type Post = {
slug: string
title: string
description: string
tags: string[]
publishedAt: string // ISO 8601 with timezone
updatedAt: string
}
export const getPost = cache(
async (slug: string): Promise<Post | null> => readPost(slug)
)
export const getAllPosts = cache(async (): Promise<Post[]> => readAllPosts())// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPost } from '@/lib/posts'
import { siteName } from '@/lib/site'
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()
const path = `/blog/${post.slug}`
return {
title: post.title,
description: post.description,
alternates: { canonical: path },
openGraph: {
type: 'article',
siteName,
url: path,
title: post.title,
description: post.description,
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
},
}
}After next typegen generates route types, the global PageProps<'/blog/[slug]'> helper can replace Props.
Canonical URLs
alternates.canonical renders a <link rel="canonical"> resolved against metadataBase, so it comes out absolute, as Google recommends. Three habits keep canonicals honest:
- Set canonicals per page, not in the root layout. Metadata inherits, so
canonical: '/'there would point every page without its own canonical at your home page. - Build the path from the stored slug, not raw input, so casing variants collapse to one URL.
- Stay consistent. Google treats
rel="canonical"as a strong signal, not a directive, so internal links and the sitemap should use the same URL. Its canonicalization guide also advises against usingnoindexor robots.txt to pick canonicals.
Open Graph and Twitter cards
For articles, set Open Graph type: 'article' with publishedTime and modifiedTime. Pages that don't define twitter inherit the layout's summary_large_image card.
Social images and icons with file conventions
Instead of hand-maintaining image URLs, add an opengraph-image.tsx to the route segment. It generates the image and its og:image tags, so you don't set openGraph.images by hand. If a page's metadata does set images explicitly, check the rendered tags: an explicit value there can take precedence over the route's image file.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPost } from '@/lib/posts'
import { siteName } from '@/lib/site'
export const alt = `${siteName} article`
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={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'white',
fontSize: 64,
}}
>
{post?.title ?? siteName}
</div>
),
{ ...size }
)
}ImageResponse supports flexbox and a subset of CSS (no display: grid), fonts must be ttf, otf or woff, and the bundle limit is 500KB. Generated images are statically optimized at build time unless they use request-time APIs or uncached data. Add a twitter-image file for a different image on X.
Icons work the same way: favicon.ico must sit in the root app/ folder, icon accepts .ico, .jpg, .jpeg, .png or .svg, and apple-icon accepts .jpg, .jpeg or .png. You can also generate either with ImageResponse.
Sitemaps and robots.txt with MetadataRoute
app/sitemap.ts generates /sitemap.xml, typed with MetadataRoute.Sitemap:
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { getAllPosts } from '@/lib/posts'
import { siteUrl } from '@/lib/site'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts()
return [
{ url: siteUrl },
{ url: `${siteUrl}/blog` },
...posts.map((post) => ({
url: `${siteUrl}/blog/${post.slug}`,
lastModified: post.updatedAt,
})),
]
}Google ignores priority and changefreq and trusts lastmod only when it is consistently accurate, so use real edit dates, not new Date() on every build. List only canonical, indexable URLs, at most 50,000 per file; generateSitemaps splits larger sets, and its id is a Promise in Next.js 16. sitemap.ts is cached by default unless it uses request-time APIs.
If it reads from PostgreSQL, keep that query cheap; see my PostgreSQL indexing guide.
app/robots.ts returns a MetadataRoute.Robots object:
// app/robots.ts
import type { MetadataRoute } from 'next'
import { siteUrl } from '@/lib/site'
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/', disallow: '/admin/' },
sitemap: `${siteUrl}/sitemap.xml`,
}
}Robots.txt controls crawling, not indexing. A disallowed URL can still be indexed if other sites link to it, and blocking scripts or styles a page needs makes it harder for Google to understand.
Structured data with JSON-LD
JSON-LD describes a page in schema.org vocabulary and is the format Google recommends. The Next.js docs recommend a native <script> tag in a layout or page, not next/script, because JSON-LD is data, not executable code.
// app/blog/[slug]/page.tsx (continued)
import { siteUrl } from '@/lib/site'
export default async function Page({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
if (!post) notFound()
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: { '@type': 'Person', name: 'Your Name', url: `${siteUrl}/about` },
}
return (
<article>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
}}
/>
<h1>{post.title}</h1>
{/* post body */}
</article>
)
}The .replace(/</g, '\\u003c') matters. JSON.stringify does not escape HTML, so a title containing </script> would close the tag early and let what follows be parsed as markup. Escaping < keeps the JSON valid and the tag intact. For typed objects, the docs suggest the community schema-dts package.
Google's structured data guidelines add two rules: mark up only content visible on the page, and don't expect guaranteed rich results. For articles, use Person authors with a url and ISO 8601 dates with a timezone. Validate with the Rich Results Test and the Schema Markup Validator.
Keeping thin pages out of the index
Placeholder pages, near-empty tag archives and internal search results rarely deserve to rank. The robots field handles them:
// app/tags/[tag]/page.tsx
import type { Metadata } from 'next'
import { getAllPosts } from '@/lib/posts'
type Props = { params: Promise<{ tag: string }> }
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { tag } = await params
const posts = (await getAllPosts()).filter((p) => p.tags.includes(tag))
return {
title: `Posts tagged ${tag}`,
robots: { index: posts.length >= 3, follow: true },
}
}Below your chosen threshold this renders noindex, follow, so crawlers still reach the posts. Two traps: a noindex page must stay crawlable, because a robots.txt block hides the tag from Google, and don't count on removing noindex later with client-side JavaScript, since Google may skip rendering once it sees it.
Internal links and descriptive anchor text
Google reliably crawls only links that are <a> elements with an href. next/link renders exactly that, so the usual failure is navigation wired through a click handler, such as a button calling router.push(), which leaves crawlers nothing to follow.
Anchor text should make sense out of context: a post's title beats "read more". Link every page you care about from at least one other page, always to its canonical URL; related-post sections make this easy on a blog.
One H1 and a clear heading outline
Google says heading count and order don't affect ranking. I still give every page exactly one H1 matching its title, with H2s and H3s in order beneath it, because a semantic outline helps screen-reader users and skimmers. On a blog, render the post title as the H1, keep Markdown content at H2 and below, and keep the site logo out of heading tags.
Pre-launch checklist
- View source (not DevTools): main content, title and description are in the server HTML.
metadataBaseand canonicals use the production origin.- Every indexable page has a unique title, description and canonical.
- Each content type has an
opengraph-image, and theog:imageURL loads. /sitemap.xmllists only canonical, indexable URLs with accuratelastModified./robots.txtreferences the sitemap and blocks nothing you need crawled.- Thin pages are
noindex; missing content returns a404ornoindex. - JSON-LD passes the Rich Results Test and matches visible content.
- Links are
<a href>elements with descriptive text. - Preview and staging deployments stay out of the index.
Key takeaways
- Put indexable content in the server HTML; Server Components make this the default.
- Set
metadataBase, a title template and shared Open Graph values in the root layout; merging is shallow. - Use
generateMetadatawith awaitedparamsand a cached loader for per-page titles, descriptions and canonicals. - Let file conventions handle OG images, icons, sitemaps and robots.txt.
- Render JSON-LD in a native script tag and escape
<. - Apply
noindexto thin pages without blocking them in robots.txt.
If you want a second pair of eyes on your app, technical SEO audits are part of my performance and SEO work.
