SEO for Next.js: The Checklist Most Developers Skip
Next.js ships with first-class SEO support. Metadata API, dynamic OG images, sitemap generation, robots.txt, structured data via JSON-LD. Most developers set a title and description and call it done.
Here's everything you're probably missing.
The Metadata API
Every page should export metadata. Not just title and description — the full set:
export const metadata: Metadata = {
title: "Pricing",
description: "Simple pricing for teams of every size.",
alternates: { canonical: "/pricing" },
openGraph: {
title: "Pricing — Your App",
description: "Simple pricing for teams of every size.",
},
}
Title Templates
Set a template in the root layout:
export const metadata: Metadata = {
metadataBase: new URL("https://yourdomain.com"),
title: {
default: "Your App — Tagline",
template: "%s — Your App",
},
}
Now a page with title: "Pricing" renders as "Pricing — Your App" in the browser tab. Consistent branding, zero repetition.
Dynamic Routes
For pages like /blog/[slug], use generateMetadata:
export async function generateMetadata({ params }): Promise<Metadata> {
const post = await getPost(params.slug)
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: `/blog/${params.slug}` },
}
}
Canonical URLs
This is the most commonly skipped SEO element. Without canonicals, Google may index URL variants as separate pages:
/components ← one page
/components?sort=name ← Google thinks this is a different page
/components/ ← and this
Three pages competing for the same ranking. Set a canonical on every page:
alternates: { canonical: "/pricing" }
Combined with metadataBase, this resolves to https://yourdomain.com/pricing.
Dynamic OG Images
Next.js generates Open Graph images at build time with opengraph-image.tsx:
// app/opengraph-image.tsx
import { ImageResponse } from "next/og"
export default function Image() {
return new ImageResponse(
<div style={{ height: "100%", width: "100%", display: "flex", ... }}>
<h1>Your App</h1>
</div>,
{ width: 1200, height: 630 }
)
}
For dynamic routes, place opengraph-image.tsx inside the route folder. Each blog post gets a unique branded social card — no external image service needed.
If you don't set OG images, social platforms show a generic preview or nothing. Your content gets less engagement, fewer clicks, less traffic.
Sitemaps
Create app/sitemap.ts to generate your sitemap dynamically:
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getPublishedPosts()
const staticRoutes = [
{ url: "https://yourdomain.com", priority: 1.0 },
{ url: "https://yourdomain.com/blog", priority: 0.8 },
]
const dynamicRoutes = posts.map((post) => ({
url: `https://yourdomain.com/blog/${post.slug}`,
lastModified: post.updatedAt,
priority: 0.6,
}))
return [...staticRoutes, ...dynamicRoutes]
}
Common mistakes:
- Only listing static routes — your dynamic pages (
/blog/[slug],/components/[slug]) are your highest-value long-tail pages. Include them. - Using
new Date()for lastModified — this tells Google everything changed on every build. Use actual modification dates. - Including private routes — don't put
/dashboardor/settingsin your sitemap.
Robots.txt
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/dashboard/", "/api/", "/settings/", "/login", "/signup"],
},
sitemap: "https://yourdomain.com/sitemap.xml",
}
}
Block auth pages, API routes, and authenticated areas. Always include a sitemap reference.
Structured Data (JSON-LD)
JSON-LD helps Google understand your content and display rich results (FAQ accordions, product cards, breadcrumbs).
const jsonLd = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "Your App",
applicationCategory: "DeveloperApplication",
offers: { "@type": "Offer", price: "29", priceCurrency: "USD" },
}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
Useful schemas for SaaS:
| Schema | Use | Rich result |
|---|---|---|
| `SoftwareApplication` | Your product | Product card in SERPs |
| `FAQPage` | FAQ sections | Expandable Q&A in SERPs |
| `BlogPosting` | Blog articles | Article card with date/author |
| `Organization` | Your company | Knowledge panel |
| `BreadcrumbList` | Page hierarchy | Breadcrumb trail in SERPs |
The Full Checklist
Per page:
- [ ] Title (unique, under 60 characters, includes primary keyword)
- [ ] Description (unique, under 160 characters, includes CTA or value prop)
- [ ] Canonical URL
- [ ] Open Graph title + description
- [ ] OG image (1200x630)
Per site:
- [ ]
metadataBaseset in root layout - [ ] Title template with brand name
- [ ]
sitemap.tswith ALL public pages (static + dynamic) - [ ]
robots.tsblocking private routes - [ ]
lang="en"on<html>element - [ ] JSON-LD on homepage (product + FAQ)
- [ ] JSON-LD on blog posts
- [ ] Custom 404 page
Often missed:
- [ ] Dynamic routes in the sitemap
- [ ] Canonical URLs on every page (not just homepage)
- [ ] Per-blog-post OG images
- [ ] Actual
lastModifieddates (notnew Date())
Most of these take 5 minutes each. Collectively, they're the difference between Google understanding your site and Google guessing.