SEO for Next.js
The SEO essentials for a Next.js App Router project: metadata, titles, headings, semantic HTML, canonical URLs, sitemap, robots, Open Graph image and JSON-LD.
October 26, 2026
8 min read
The SEO essentials for a Next.js App Router project: metadata, titles, headings, semantic HTML, canonical URLs, sitemap, robots, Open Graph image and JSON-LD.
October 26, 2026
8 min read
Next.js builds the <head> from what you export. You never write <meta> tags by hand.
metadata for fixed valuesgenerateMetadata for values that come from datasitemap.ts, robots.ts, opengraph-image.tsx, manifest.ts, iconsMetadata exports only work in Server Components.
app/
├── layout.tsx site-wide metadata, <html lang>, JSON-LD
├── page.tsx home page metadata and canonical
├── not-found.tsx 404 page
├── sitemap.ts → /sitemap.xml
├── robots.ts → /robots.txt
├── manifest.ts → /manifest.webmanifest
├── opengraph-image.tsx social preview image
├── favicon.ico
├── icon.png
└── apple-icon.png// app/layout.tsx
import type { Metadata } from "next"
export const metadata: Metadata = {
metadataBase: new URL("https://example.com"),
title: {
default: "Example: Fast, Accessible Websites",
template: "%s | Example",
},
description: "We design and build fast, accessible websites with Next.js.",
openGraph: { type: "website", siteName: "Example", locale: "en_US" },
twitter: { card: "summary_large_image" },
verification: { google: "your-search-console-token" },
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}metadataBase lets every other field use relative URLs.title.template turns a page's title: "About" into About | Example.lang tells search engines and screen readers the page language.verification.google verifies the site in Search Console.Weak: Home | Example
Strong: Website Design and Development | ExampleEvery page sets its own title, description and canonical:
// app/about/page.tsx
import type { Metadata } from "next"
export const metadata: Metadata = {
title: "About",
description: "Who we are, what we build and how to work with us.",
alternates: { canonical: "/about" },
}When the content comes from a CMS or database, use generateMetadata:
// app/[slug]/page.tsx
import type { Metadata } from "next"
import { notFound } from "next/navigation"
type Props = { params: Promise<{ slug: string }> }
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const page = await getPage(slug)
if (!page) return {}
return {
title: page.title,
description: page.description,
alternates: { canonical: `/${slug}` },
}
}
export default async function Page({ params }: Props) {
const { slug } = await params
const page = await getPage(slug)
if (!page) notFound() // a real 404, not a "not found" page with status 200
return <main>{/* ... */}</main>
}Wrap getPage in React's cache so the metadata and the page fetch once.
Metadata merges shallowly. If a page sets openGraph, the layout's whole openGraph object is replaced, not combined. Repeat the shared fields, or build them with a helper.
One page, one URL.
alternates: { canonical: "/" } in app/page.tsx.www or not) and HTTPS, and permanently redirect the rest. Do it at your hosting provider, or in next.config.ts with redirects() and permanent: true.h1 per page, describing the page.h2 for sections, h3 inside them. Pick the level for structure, not size.<header>, <nav>, <main>, <section>, <footer>.<Link> renders an <a href> that crawlers follow. A clickable <div> is invisible to them.<>
<header>
<nav>{/* links */}</nav>
</header>
<main>
<h1>Website Design and Development</h1>
<section>
<h2>Pricing</h2>
{/* ... */}
</section>
</main>
<footer>{/* ... */}</footer>
</>import Image from "next/image"
<Image src="/hero.png" alt="Team reviewing a website design" width={1200} height={630} preload />alt describes the image. Decorative images get alt="".width and height prevent layout shift.preload only on the main image above the fold. It replaced priority in Next.js 16.The preview card shown when a link is shared. Generate it with ImageResponse:
// app/opengraph-image.tsx
import { ImageResponse } from "next/og"
export const alt = "Example: Fast, Accessible Websites"
export const size = { width: 1200, height: 630 }
export const contentType = "image/png"
export default function Image() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
padding: 80,
background: "#0a0a0a",
color: "#fff",
fontSize: 72,
fontWeight: 700,
}}
>
Fast, accessible websites
</div>
),
size,
)
}og:image tags, size and alt for you.opengraph-image.tsx inside a route folder overrides it there. In a dynamic route it gets params as a promise.fonts option.opengraph-image.png (1200×630) works too.Describe the site in the root layout. Escape < so the content cannot close the tag:
// app/layout.tsx
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
{ "@type": "WebSite", name: "Example", url: "https://example.com" },
{
"@type": "Organization",
name: "Example",
url: "https://example.com",
logo: "https://example.com/icon.png",
},
],
}
// inside <body>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
/>Use Person for a personal site and LocalBusiness for a business with an address. Check it with the Rich Results Test.
// app/sitemap.ts
import type { MetadataRoute } from "next"
export default function sitemap(): MetadataRoute.Sitemap {
return [
{ url: "https://example.com", lastModified: "2026-10-01" },
{ url: "https://example.com/about", lastModified: "2026-09-12" },
]
}noindex pages.new Date() says every page changed on every crawl, and Google stops trusting it.changeFrequency or priority. Google ignores both.// app/robots.ts
import type { MetadataRoute } from "next"
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: "*", allow: "/", disallow: "/api/" },
sitemap: "https://example.com/sitemap.xml",
}
}Disallow stops crawling, not indexing. To keep a page out of Google, let it be crawled and set robots: { index: false } in its metadata.
Staging sites: send an X-Robots-Tag: noindex header for that host from headers() in next.config.ts, and do not disallow it in robots.txt, or crawlers never see the noindex.
// app/manifest.ts
import type { MetadataRoute } from "next"
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Example",
short_name: "Example",
start_url: "/",
display: "standalone",
background_color: "#0a0a0a",
theme_color: "#0a0a0a",
icons: [{ src: "/icon-512.png", sizes: "512x512", type: "image/png" }],
}
}favicon.ico, icon.png and apple-icon.png in app/ become <link> tags automatically.
metadataBase, title template and <html lang> in the root layouth1 per page, headings in order, semantic landmarksalt on every meaningful imageWebSite and Organization JSON-LDsitemap.ts with real dates, robots.ts pointing to itnoindexhttps://example.com/sitemap.xml.No. The Metadata API and the special files cover everything.
metadata for fixed values, generateMetadata when they depend on params or data.
No. It only stops crawling. Use noindex and let the page be crawled.
Usually not an error. Check that it has its own canonical, original content and links from other pages, then request indexing and wait.