What This Template Covers
This template provides a complete SEO configuration for Next.js App Router projects. Each section includes working code you can copy directly into your project and adapt to your URL structure and content types.
Next.js App Router has first-class SEO support through its metadata API, file-based routing, and built-in optimizations. This template shows you how to use every relevant feature.
Section 1: Metadata Configuration
Root layout metadata
Set default metadata and a title template in your root layout:
// app/layout.tsx
import type { Metadata } from "next"
export const metadata: Metadata = {
title: {
default: "YourSite — Tagline",
template: "%s | YourSite"
},
description: "Your site's default description (120-155 characters).",
metadataBase: new URL("https://yoursite.com"),
alternates: {
canonical: "/"
}
}
The template pattern means child pages only need to set title: "Page Title" and it automatically becomes "Page Title | YourSite".
Dynamic page metadata
// app/blog/[slug]/page.tsx
import type { Metadata } from "next"
export async function generateMetadata({ params }): Promise<Metadata> {
const post = await getPost(params.slug)
return {
title: post.title,
description: post.metaDescription,
alternates: {
canonical: `/blog/${post.slug}`
},
openGraph: {
title: post.title,
description: post.metaDescription,
type: "article",
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
authors: [post.author]
}
}
}
Keywords and robots directives
export const metadata: Metadata = {
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-snippet": -1,
"max-image-preview": "large",
"max-video-preview": -1
}
}
}
Section 2: Sitemap Generation
Dynamic sitemap
// app/sitemap.ts
import type { MetadataRoute } from "next"
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = "https://yoursite.com"
// Static pages
const staticPages = [
{ url: baseUrl, lastModified: new Date(), priority: 1.0 },
{ url: `${baseUrl}/about`, lastModified: new Date(), priority: 0.5 },
{ url: `${baseUrl}/pricing`, lastModified: new Date(), priority: 0.8 }
]
// Dynamic pages from CMS/database
const posts = await getAllPosts()
const postPages = posts.map(post => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: "weekly" as const,
priority: 0.7
}))
return [...staticPages, ...postPages]
}
Sitemap index for large sites
For sites with 10,000+ pages, split into multiple sitemaps:
// app/sitemap.ts
export async function generateSitemaps() {
const totalPosts = await getPostCount()
const sitemapCount = Math.ceil(totalPosts / 5000)
return Array.from({ length: sitemapCount }, (_, i) => ({ id: i }))
}
export default async function sitemap({ id }): Promise<MetadataRoute.Sitemap> {
const start = id * 5000
const posts = await getPosts({ offset: start, limit: 5000 })
// ... return formatted entries
}
Section 3: Robots.txt
// app/robots.ts
import type { MetadataRoute } from "next"
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/api/", "/dashboard/", "/admin/"]
},
{
userAgent: "GPTBot",
allow: "/"
},
{
userAgent: "ClaudeBot",
allow: "/"
}
],
sitemap: "https://yoursite.com/sitemap.xml"
}
}
Allow AI crawlers explicitly if you want AI search visibility. Block authenticated routes that shouldn't be indexed.
Section 4: Structured Data
Reusable JSON-LD component
// components/json-ld.tsx
export function JsonLd({ data }: { data: Record<string, unknown> }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
)
}
Article schema
// In your blog post page
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
description: post.description,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
"@type": "Person",
name: post.author
},
publisher: {
"@type": "Organization",
name: "YourSite",
logo: {
"@type": "ImageObject",
url: "https://yoursite.com/logo.png"
}
}
}}
/>
FAQ schema
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faqs.map(faq => ({
"@type": "Question",
name: faq.question,
acceptedAnswer: {
"@type": "Answer",
text: faq.answer
}
}))
}}
/>
BreadcrumbList schema
<JsonLd
data={{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: "https://yoursite.com"
},
{
"@type": "ListItem",
position: 2,
name: "Blog",
item: "https://yoursite.com/blog"
},
{ "@type": "ListItem", position: 3, name: post.title }
]
}}
/>
Section 5: Open Graph and Twitter Cards
Default OG configuration
// app/layout.tsx metadata
openGraph: {
type: "website",
locale: "en_US",
url: "https://yoursite.com",
siteName: "YourSite",
images: [
{
url: "https://yoursite.com/og-default.jpg",
width: 1200,
height: 630,
alt: "YourSite",
},
],
},
twitter: {
card: "summary_large_image",
site: "@yoursite",
},
Dynamic OG images with Next.js
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og"
export const size = { width: 1200, height: 630 }
export const contentType = "image/png"
export default async function OGImage({ params }) {
const post = await getPost(params.slug)
return new ImageResponse(
(
<div style={{ display: "flex", flexDirection: "column", padding: 60 }}>
<h1 style={{ fontSize: 48 }}>{post.title}</h1>
<p style={{ fontSize: 24, color: "#666" }}>yoursite.com</p>
</div>
),
size
)
}
Section 6: Performance Optimization
Image optimization
import Image from "next/image"
// Always use next/image for automatic optimization
;<Image
src="/hero.jpg"
alt="Hero description"
width={1200}
height={630}
priority // Preload above-fold images
/>
next/image automatically:
- Converts to WebP/AVIF
- Generates responsive srcset
- Lazy loads below-fold images
- Sets width/height to prevent CLS
Font optimization
// app/layout.tsx
import { Inter } from "next/font/google"
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
})
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.variable}>
<body>{children}</body>
</html>
)
}
next/font automatically:
- Self-hosts font files (no external requests)
- Calculates
size-adjustfor zero CLS - Subsets to reduce file size
Script optimization
import Script from "next/script"
// Defer non-critical third-party scripts
;<Script src="https://analytics.example.com/script.js" strategy="lazyOnload" />
Section 7: Internationalization SEO
Locale-based routing
// next.config.js
module.exports = {
i18n: {
locales: ["en", "es", "fr", "de"],
defaultLocale: "en"
}
}
Hreflang through alternates
export async function generateMetadata({ params }): Promise<Metadata> {
return {
alternates: {
canonical: `/en/blog/${params.slug}`,
languages: {
en: `/en/blog/${params.slug}`,
es: `/es/blog/${params.slug}`,
fr: `/fr/blog/${params.slug}`,
"x-default": `/en/blog/${params.slug}`
}
}
}
}
This generates the correct <link rel="alternate" hreflang="..."> tags automatically.
Section 8: Monitoring and Validation
Lighthouse CI
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [push]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- run: npm start &
- uses: treosh/lighthouse-ci-action@v11
with:
urls: |
http://localhost:3000/
http://localhost:3000/blog/sample-post
budgetPath: ./lighthouse-budget.json
Meta tag validation script
#!/bin/bash
# Verify critical SEO elements exist in production HTML
URL="https://yoursite.com"
check_page() {
local path=$1
local html=$(curl -s "$URL$path")
echo "Checking $path..."
echo "$html" | grep -q '<title>' || echo " MISSING: title tag"
echo "$html" | grep -q 'meta name="description"' || echo " MISSING: meta description"
echo "$html" | grep -q 'rel="canonical"' || echo " MISSING: canonical"
echo "$html" | grep -q 'application/ld+json' || echo " MISSING: structured data"
}
check_page "/"
check_page "/blog/sample-post"
check_page "/pricing"
Frequently Asked Questions
Does Next.js handle SEO automatically?
Next.js provides the infrastructure (metadata API, SSR/SSG, image optimization), but you need to configure it per page. The framework doesn't auto-generate titles, descriptions, or structured data — you define them through the metadata API.
Should I use App Router or Pages Router for SEO?
App Router has better SEO primitives: generateMetadata, file-based sitemap.ts, robots.ts, and streaming SSR. For new projects, use App Router. For existing Pages Router projects, the SEO capabilities are adequate but require more manual configuration.
How do I handle 404 pages for SEO?
// app/not-found.tsx
export const metadata = { title: "Page Not Found" }
export default function NotFound() {
return <h1>404 - Page Not Found</h1>
}
Next.js automatically returns a 404 status code for the not-found.tsx page.
Can I use Next.js middleware for SEO redirects?
Yes. Middleware runs at the edge before the page renders:
// middleware.ts
export function middleware(request) {
const redirects = { "/old-path": "/new-path" }
const target = redirects[request.nextUrl.pathname]
if (target) {
return NextResponse.redirect(new URL(target, request.url), 301)
}
}
Related Resources
- JavaScript SEO — How search engines process JavaScript
- SPA SEO Optimization — Use case for single-page apps
- JavaScript SEO Best Practices — Technical checklist