How to Make Next.js SEO-Friendly
Deep technical guide to Next.js SEO: App Router metadata API, generateMetadata, sitemap.ts, robots.ts, structured data, OG images, and performance optimization.
Next.js has built-in SEO features (Metadata API, sitemap.ts, robots.ts, Image optimization) but you have to use them correctly. This guide covers every App Router SEO feature with production-ready TypeScript examples.
Next.js is one of the best frameworks for SEO. Server-side rendering, the Metadata API, built-in sitemap and robots support, image optimization — it gives you everything you need. But you still have to use these features correctly.
This guide covers every SEO feature in Next.js 15+ App Router, with production-ready code examples.
Static metadata with the metadata export
For pages with fixed metadata, export a metadata object directly from your page or layout:
// app/page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Indxel — SEO infrastructure for developers',
description: 'Validate SEO on every deploy. CLI, SDK, and dashboard for modern dev teams.',
openGraph: {
title: 'Indxel — SEO infrastructure for developers',
description: 'Validate SEO on every deploy.',
url: 'https://indxel.com',
siteName: 'Indxel',
images: [{ url: 'https://indxel.com/og.png', width: 1200, height: 630 }],
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: 'Indxel — SEO infrastructure for developers',
description: 'Validate SEO on every deploy.',
},
alternates: {
canonical: 'https://indxel.com',
},
}
This works for your homepage, pricing page, about page — any page where the content doesn't depend on dynamic data.
Dynamic metadata with generateMetadata
For pages that depend on params (blog posts, product pages), use generateMetadata:
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
type Props = { params: Promise<{ slug: string }> }
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = getPostBySlug(slug)
return {
title: post.metaTitle,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
type: 'article',
publishedTime: post.publishedAt,
images: [`https://indxel.com/og/blog/${slug}.png`],
},
alternates: {
canonical: `https://indxel.com/blog/${slug}`,
},
}
}
Next.js calls generateMetadata at build time for static pages and at request time for dynamic pages. It automatically deduplicates fetch requests between generateMetadata and the page component.
Metadata templates with layouts
Use layout metadata to set defaults and templates that apply to all child pages:
// app/layout.tsx
export const metadata: Metadata = {
metadataBase: new URL('https://indxel.com'),
title: {
template: '%s | Indxel',
default: 'Indxel — SEO infrastructure for developers',
},
description: 'SEO infrastructure for developers.',
openGraph: {
siteName: 'Indxel',
},
}
Now any child page that sets title: 'Pricing' automatically gets "Pricing | Indxel" as the full title. The metadataBase resolves all relative URLs in metadata to absolute URLs.
Generating a sitemap
Next.js supports sitemaps natively with app/sitemap.ts:
// app/sitemap.ts
import type { MetadataRoute } from 'next'
export default function sitemap(): MetadataRoute.Sitemap {
const staticPages = [
{ url: 'https://indxel.com', lastModified: new Date(), changeFrequency: 'weekly' as const, priority: 1 },
{ url: 'https://indxel.com/pricing', lastModified: new Date(), changeFrequency: 'monthly' as const, priority: 0.8 },
]
const blogPages = getAllPosts().map(post => ({
url: `https://indxel.com/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: 'monthly' as const,
priority: 0.6,
}))
return [...staticPages, ...blogPages]
}
This generates /sitemap.xml automatically. Submit it to Google Search Console after your first deploy.
Robots.txt configuration
Control what search engines can crawl with app/robots.ts:
// app/robots.ts
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/dashboard/', '/auth/'],
},
],
sitemap: 'https://indxel.com/sitemap.xml',
}
}
Always disallow private routes (API endpoints, dashboard, auth). Always include your sitemap URL. Consider allowing AI bots explicitly if you want GEO (Generative Engine Optimization) visibility.
JSON-LD structured data
Add JSON-LD directly in your page components. Next.js renders it server-side so search engines always see it:
// app/blog/[slug]/page.tsx
export default function BlogPost({ post }) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
'@type': 'Organization',
name: 'Indxel',
url: 'https://indxel.com',
},
publisher: {
'@type': 'Organization',
name: 'Indxel',
logo: { '@type': 'ImageObject', url: 'https://indxel.com/logo.png' },
},
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>...</article>
</>
)
}
Add Organization schema in your root layout, Article schema on blog posts, FAQ schema on pricing pages, and Breadcrumb schema everywhere.
Dynamic OG images with next/og
Generate Open Graph images dynamically using Next.js ImageResponse:
// app/og/[slug]/route.tsx
import { ImageResponse } from 'next/og'
export async function GET(request: Request, { params }: { params: { slug: string } }) {
const post = getPostBySlug(params.slug)
return new ImageResponse(
(
<div style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
width: '100%',
height: '100%',
backgroundColor: '#09090B',
color: '#FAFAFA',
padding: '60px',
}}>
<h1 style={{ fontSize: 48, fontWeight: 700 }}>{post.title}</h1>
<p style={{ fontSize: 24, color: '#A1A1AA' }}>{post.description}</p>
</div>
),
{ width: 1200, height: 630 }
)
}
This generates unique OG images for every page at request time. No Figma, no manual exports. Reference them in your metadata with images: ['/og/your-slug'].
Image optimization with next/image
Always use next/image instead of raw <img> tags. It provides:
- Automatic WebP/AVIF conversion
- Responsive srcset generation
- Lazy loading by default
- Explicit width/height to prevent CLS
import Image from 'next/image'
<Image
src="/hero.png"
alt="Indxel dashboard showing SEO scores"
width={1200}
height={630}
priority // Use for above-the-fold images
/>
Set priority on your LCP image (usually the hero image) to preload it. This directly improves your Largest Contentful Paint score.
Font optimization with next/font
Loading fonts incorrectly causes layout shift (CLS) and slow rendering (LCP). Next.js solves both:
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'], display: 'swap' })
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
)
}
Next.js self-hosts the font files (no external requests to Google Fonts), inlines the CSS, and uses font-display: swap to prevent invisible text during loading. Zero CLS from fonts.
Rendering strategies and SEO
Next.js App Router gives you multiple rendering options. For SEO, the key distinction is whether the HTML is available to crawlers:
- Static (SSG) — best for SEO. HTML generated at build time. Use for landing pages, blog posts, docs.
- Server (SSR) — good for SEO. HTML generated per request. Use for pages with frequently changing data.
- Client (CSR) — risky for SEO. Content loaded via JavaScript. Google can render JS but it's slower and less reliable.
Rule of thumb: if a page needs to rank in search, render it on the server. Use 'use client' only for interactive components, not for page-level content.
Validating everything with Indxel
All of the above is a lot to remember. Indxel validates it automatically:
# Check all pages in your Next.js app
$ npx indxel check
/ 98/100 title, description, og, jsonld, canonical
/pricing 95/100 title, description, og, jsonld, canonical
/blog 92/100 title, description, og (missing jsonld)
/blog/nextjs-seo 97/100 title, description, og, jsonld, canonical
Overall: 95/100 (A)
44/47 pages pass all checks
Add --ci to fail the build on regressions. Add --diff to see what changed between deploys. That's the full stack: Next.js gives you the tools, Indxel makes sure you're using them correctly.
For the conceptual overview behind these patterns, see our complete Next.js SEO guide. To validate your OG images specifically, try the OG tags checker.