Complete Next.js SEO Guide
A production-ready Next.js SEO workflow: Metadata API, per-post metadata, JSON-LD, Open Graph, sitemap and robots, plus CI checks. Includes next-seo and next-sitemap tips.
DJ Lim
Founder & CEO
Complete Next.js SEO Guide
Shipping is fast. Keeping search engines happy without a CMS is hard. Titles drift, canonical tags vanish, and your Next.js sitemap quietly stops including new posts.
This is a complete workflow for Next.js SEO. It uses the native Metadata API for per post metadata, Open Graph, Twitter cards, JSON-LD, and first party app/sitemap.ts and app/robots.ts. Automated checks keep it from breaking again.
The checklist that never regresses
Wire these once, and they'll stay consistent:
- Site defaults with the Next.js Metadata API
- Per post
generateMetadatamapped from MDX frontmatter - Canonical URLs and alternates
- Open Graph and Twitter images
- Article JSON-LD for posts
- First party
app/sitemap.tsandapp/robots.ts - CI checks for missing descriptions, broken links, and sitemap shape
Together, these give you consistent Next.js SEO without adding a CMS.
Site wide defaults with the Metadata API
Define a single source of truth in app/layout.tsx using export const metadata and metadataBase.
export const metadata: Metadata = {
title: {
default: 'Brand',
template: '%s · Brand'
},
description: 'What you do in one sentence',
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'),
openGraph: {
type: 'website',
url: '/',
images: ['/og.png']
},
twitter: {
card: 'summary_large_image',
site: '@brand'
}
}Tip: teams often see broken social images on Vercel because image URLs are relative. Set NEXT_PUBLIC_SITE_URL to your production domain so openGraph.images and canonical links resolve to absolute URLs.
Docs: Next.js Metadata overview and API Optimizing: Metadata and generateMetadata.
Per post metadata from MDX
Make generateMetadata build tags from frontmatter in app/blog/[slug]/page.tsx.
Example shape with a safe image fallback:
export async function generateMetadata({ params }): Promise<Metadata> {
const fm = await getFrontmatter(params.slug)
const url = '/blog/' + fm.slug
const image = fm.image || '/og/default.png'
return {
title: fm.title,
description: fm.description,
alternates: { canonical: url },
openGraph: {
type: 'article',
url,
title: fm.title,
description: fm.description,
images: [image]
},
twitter: {
card: 'summary_large_image',
title: fm.title,
description: fm.description,
images: [image]
}
}
}This keeps Next.js metadata in one place, driven by frontmatter so engineers do not hand write tags.
Add Article JSON-LD
Structured data improves rich result eligibility and helps search engines and AI systems understand your content.
Types: add schema-dts for type safety.
import type { Article } from 'schema-dts'
const data: Article = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: fm.title,
description: fm.description,
datePublished: fm.date,
dateModified: fm.lastmod || fm.date,
author: {
'@type': 'Person',
name: fm.author
},
image: image,
mainEntityOfPage: new URL(url, process.env.NEXT_PUBLIC_SITE_URL).toString()
}Render inline in the page component:
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>Guides: Next.js JSON-LD how to JSON-LD and Google policies Search Gallery.
First party sitemap.xml and robots.txt
Generate both from code with the App Router.
app/sitemap.ts:
import type { MetadataRoute } from 'next'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await allPosts()
return [
{ url: 'https://example.com/', lastModified: new Date() },
...posts.map(p => ({
url: 'https://example.com/blog/' + p.slug,
lastModified: p.lastmod || p.date
}))
]
}app/robots.ts:
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
const site = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'
return {
rules: { userAgent: '*' },
sitemap: site + '/sitemap.xml',
host: site
}
}Docs: robots.txt and the sitemap file convention in the metadata section. If you already run next-sitemap, you can keep it for very large or multi domain sites. Otherwise, the built in API is simpler. Migration notes: migrate to App Directory sitemap.
Dynamic OG images without the yak shave
Two options that work well:
-
Static images: one per post in
public/og/<slug>.png. SetopenGraph.images: ['/og/<slug>.png']and keep naming deterministic from frontmatter. -
Dynamic images with
@vercel/og: add a route atapp/og/route.tsx:
import { ImageResponse } from '@vercel/og'
export const runtime = 'edge'
export async function GET(request: Request) {
return new ImageResponse(
<div>...</div>,
{ width: 1200, height: 630 }
)
}Then set openGraph.images to '/og?title=' + encodeURIComponent(fm.title).
Confirm the final URLs are absolute via metadataBase so cards render correctly off site.
Where next-seo and next-sitemap fit now
The App Router covers most tags through the Metadata API. next-seo is still fine if you like component ergonomics or you are migrating a Pages Router site. For new App Router projects, start native. Keep a tiny helper, for example buildArticleMeta(frontmatter): Metadata, so no one repeats openGraph or twitter structures. For advanced multi locale sitemaps or millions of URLs, next-sitemap remains useful.
next-seo repo: next-seo on GitHub.
Automated checks that catch SEO drift
Set up CI checks to catch missing metadata and broken links. Here's an example using GitHub Actions (you can adapt this to your CI platform):
name: seo-checks
on: [pull_request, push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck && pnpm lint && pnpm build
- name: Start preview
run: pnpm next start & sleep 5
- name: Metadata lint
run: pnpm tsx scripts/check-metadata.ts
- name: Link check
run: npx linkinator http://localhost:3000 --retry 2 --timeout 4000
- name: Sitemap smoke test
run: curl -s http://localhost:3000/sitemap.xml | grep '/blog/'Notes:
- The metadata script should fail if
titleordescriptionare missing, ifdescription.lengthis outside 70 to 160, or ifimageis missing for posts. - For
linkinator, you can switch tolycheeif you prefer a Rust binary. - Cache
pnpmand.next/cacheto keep feedback under five minutes.
MDX frontmatter that scales
Pick a stable set of keys and stick to them.
- Required:
title,description,slug,date,image. - Optional:
lastmod,tags,canonicaloverride. - Derived: compute
readingTime,url, andogpath in one helper.
When frontmatter is stable, your generateMetadata, sitemap entries, and JSON-LD all stay consistent. This is the fastest way to prevent SEO drift and maintain reliable metadata across your site.
How Elevor helps
Elevor writes drafts in your voice and opens pull requests with consistent frontmatter, Next.js metadata, JSON-LD, and a suggested OG image. The PR includes a summary of changes, so a founder can review in minutes. On merge, your site publishes without touching a CMS. Weekly Autopilot keeps the cadence, and you keep full control.
If you care about search engines and AI systems citing your work, keep your content verifiable and structured. We wrote about that here Why AI Citations Matter. For a broader view on lean search strategy, see Startup SEO Strategy for the AI Overviews Era.
Quick start plan
- Add site defaults in
app/layout.tsxwith the Metadata API. - Implement
generateMetadataforapp/blog/[slug]/page.tsxfrom MDX frontmatter. - Add Article JSON-LD with an inline script typed via
schema-dts. - Create
app/sitemap.tsandapp/robots.ts. - Wire a CI job that fails on missing metadata or broken links.
- Let Elevor open your next content PR so the structure stays correct.
Enhanced by Elevor, verified by DJ Lim.