Next.js SEO in 2026: Metadata, sitemap.ts, JSON-LD and OG Images
A practical guide to wiring up Next.js SEO in 2026 — metadata API, sitemap.ts, JSON-LD structured data, and dynamic OG image generation with zero guesswork.
Where Next.js SEO Actually Stands in 2026
If you've been building with the App Router since Next.js 13, you already know the mental model shifted hard. No more next/head scattered across random components. Instead, you export a metadata object or a generateMetadata function directly from your page.tsx or layout.tsx, and Next.js handles everything else. Honestly, it's a cleaner pattern — but the docs are dense and the gotchas are real.
The good news: as of Next.js 15, the metadata system is stable and production-ready. Google's crawlers handle server-rendered HTML just fine, so the SEO fundamentals you already know apply here. The bad news? A lot of tutorials still show the Pages Router approach with <Head> components, which does absolutely nothing in the App Router. Worth noting: mixing the two systems in the same project is a recipe for confusion.
This guide covers the four pillars of Next.js SEO in 2026: static and dynamic metadata, sitemap.ts, JSON-LD structured data, and OG image generation with ImageResponse. We'll go in order. Every code snippet here is production code you can drop straight into a real project.
Static and Dynamic Metadata with the Metadata API
Static metadata is the simple case. Export a metadata object from any layout.tsx or page.tsx and Next.js merges it into <head>. The merging follows a tree-walking algorithm — parent layouts provide defaults, child pages override. You want your robots and alternates.canonical set at the page level, not the root layout.
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'How to Build Glassmorphism Components',
description: 'A practical breakdown of backdrop-filter, blur, and transparency in React.',
openGraph: {
title: 'How to Build Glassmorphism Components',
description: 'A practical breakdown of backdrop-filter, blur, and transparency in React.',
url: 'https://empire-ui.com/blog/glassmorphism-card-design',
siteName: 'Empire UI',
images: [{ url: 'https://empire-ui.com/og/glassmorphism-card.png', width: 1200, height: 630 }],
type: 'article',
},
twitter: {
card: 'summary_large_image',
title: 'How to Build Glassmorphism Components',
images: ['https://empire-ui.com/og/glassmorphism-card.png'],
},
}Dynamic metadata is where it gets interesting. When your page is driven by a slug or database ID, you call generateMetadata — an async function that receives params and searchParams. The trick most people miss: Next.js deduplicates fetch calls between generateMetadata and the page component, so you won't be hitting your DB twice as long as you use the same URL and cache config.
// app/blog/[slug]/page.tsx
export async function generateMetadata(
{ params }: { params: { slug: string } }
): Promise<Metadata> {
const post = await fetchPost(params.slug) // deduplicated with page fetch
if (!post) return { title: 'Post not found' }
return {
title: post.title,
description: post.excerpt,
alternates: {
canonical: `https://empire-ui.com/blog/${post.slug}`,
},
openGraph: {
title: post.title,
description: post.excerpt,
publishedTime: post.date,
authors: ['Empire UI'],
type: 'article',
},
}
}One more thing — the title field accepts a template object at the root layout level. Set title: { template: '%s | Empire UI', default: 'Empire UI' } in app/layout.tsx and every child page's string title gets wrapped automatically. You won't have to repeat your brand name in every file. Quick aside: the %s placeholder is literal — don't forget it.
sitemap.ts: Dynamic Sitemaps Without the Pain
The old way was generating a sitemap.xml file at build time via a custom script and hoping it stayed in sync with your routes. The new way is app/sitemap.ts — a file that exports a default function returning an array of MetadataRoute.Sitemap objects. Next.js serves it as /sitemap.xml automatically. No plugin, no cron job.
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { getAllPosts } from '@/lib/blog'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts()
const postUrls = posts.map((post) => ({
url: `https://empire-ui.com/blog/${post.slug}`,
lastModified: new Date(post.date),
changeFrequency: 'monthly' as const,
priority: 0.7,
}))
return [
{ url: 'https://empire-ui.com', lastModified: new Date(), changeFrequency: 'weekly', priority: 1 },
{ url: 'https://empire-ui.com/blog', lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 },
...postUrls,
]
}In practice, changeFrequency and priority barely move the needle for Google. They've explicitly said they mostly ignore these values. What matters is the lastModified date — if it's accurate, Googlebot uses it to prioritize recrawling changed content. Set it from your actual data, not new Date() on every static page.
Got a large site — say, 10,000+ URLs? You'll want multiple sitemaps. Next.js 15 supports sitemap splitting via generateSitemaps(). Export that function alongside your default, return an array of IDs, and Next.js generates /sitemap/0.xml, /sitemap/1.xml, etc., with a sitemap index at /sitemap.xml automatically. Each split can have up to 50,000 URLs, which matches Google's limit exactly.
JSON-LD Structured Data: The Right Way
Structured data is one of those things where everyone knows they should do it and almost nobody does it right. JSON-LD is the format Google recommends. You inject it as a <script type='application/ld+json'> tag in the document <head>. In Next.js App Router, you drop it directly in your page.tsx JSX — no library required, no third-party package to maintain.
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await fetchPost(params.slug)
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.excerpt,
datePublished: post.date,
dateModified: post.updatedAt ?? post.date,
author: [{ '@type': 'Organization', name: 'Empire UI', url: 'https://empire-ui.com' }],
image: post.image,
url: `https://empire-ui.com/blog/${post.slug}`,
publisher: {
'@type': 'Organization',
name: 'Empire UI',
logo: { '@type': 'ImageObject', url: 'https://empire-ui.com/logo.png' },
},
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>{/* page content */}</article>
</>
)
}The dangerouslySetInnerHTML feels sketchy, but it's the correct pattern here. You're inserting a JSON blob — not user input — so there's no XSS risk. Just make sure nothing inside jsonLd is constructed from raw user input without sanitization. Look, this is one of those rare cases where the scary API name is misleading.
Beyond blog posts, structured data is worth adding to product pages (Product), FAQ sections (FAQPage), breadcrumb navigation (BreadcrumbList), and your organization's homepage (Organization or WebSite with a SearchAction). Google's Rich Results Test at search.google.com/test/rich-results lets you validate any URL. Run it after every deploy if structured data is part of your SEO strategy.
Worth noting: you can have multiple <script type='application/ld+json'> blocks on the same page. Google reads all of them. So a page with a BlogPosting block and a separate BreadcrumbList block is perfectly valid — you don't have to nest everything into one object.
OG Image Generation with ImageResponse
Open Graph images are the thumbnail that shows up when you share a link on Slack, Twitter, or LinkedIn. Hand-crafting 1200×630px images for every blog post doesn't scale. Next.js ships ImageResponse — a function that renders a JSX tree to a PNG at the edge, on demand.
Create app/blog/[slug]/opengraph-image.tsx and export a default function plus a size and contentType. Next.js maps it to /blog/your-slug/opengraph-image automatically and sets the correct og:image meta tag without you touching metadata at all.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { fetchPost } from '@/lib/blog'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function OGImage({ params }: { params: { slug: string } }) {
const post = await fetchPost(params.slug)
return new ImageResponse(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
padding: '60px',
width: '1200px',
height: '630px',
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3e 100%)',
fontFamily: 'sans-serif',
}}
>
<div style={{ fontSize: 48, fontWeight: 700, color: '#fff', lineHeight: 1.2, marginBottom: 24 }}>
{post?.title ?? 'Empire UI'}
</div>
<div style={{ fontSize: 24, color: '#a0a0c0' }}>empire-ui.com</div>
</div>
),
size
)
}A few sharp edges: ImageResponse only supports a subset of CSS. Flexbox works. Grid doesn't. Border-radius works on divs but not on images in some edge runtimes. Custom fonts require you to fetch the font file as an ArrayBuffer and pass it to the fonts option — but it's worth the 10 extra lines for brand consistency.
In practice, cache these images aggressively. The Cache-Control header defaults to public, max-age=31536000, immutable for statically generated routes, which is perfect. For dynamic routes that revalidate, set export const revalidate = 3600 in the file to regenerate hourly. Generating them fresh on every request for a high-traffic site will hurt your edge function bill.
robots.ts and Canonical URLs
Two more files worth adding while you're in the SEO zone: app/robots.ts and canonical URL handling. The robots file is straightforward — same pattern as sitemap, export a default function returning a MetadataRoute.Robots object.
// app/robots.ts
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{ userAgent: '*', allow: '/', disallow: ['/api/', '/admin/'] },
],
sitemap: 'https://empire-ui.com/sitemap.xml',
}
}Canonical URLs matter more than most people think. If your site is accessible at both https://empire-ui.com/blog/foo and https://www.empire-ui.com/blog/foo, Google sees two different pages. Set alternates.canonical in every generateMetadata call to the definitive URL. Even if you only have one domain, pagination creates canonical issues — /blog?page=2 should canonicalize to itself, not to /blog.
One pattern that pays off: create a utility function that builds the canonical URL from a slug and call it consistently everywhere. That way if your domain ever changes or you add a subdomain, you update one line. Hardcoding empire-ui.com in 150 generateMetadata calls is a migration nightmare you can avoid today.
Putting It All Together
The full SEO setup for a Next.js content site comes down to five files: app/layout.tsx (root metadata defaults), app/sitemap.ts, app/robots.ts, app/blog/[slug]/page.tsx (dynamic metadata + JSON-LD), and app/blog/[slug]/opengraph-image.tsx. That's it. No plugin ecosystem to maintain, no separate SEO library to keep in sync with Next.js releases.
Testing matters. Use Vercel's preview deployments — or any public URL — and run through Google's Rich Results Test, the OG Debugger at developers.facebook.com/tools/debug, and Twitter's Card Validator. All three cache aggressively, so hit the 'Scrape Again' button before trusting the result. For sitemap validation, submit to Google Search Console and monitor the Coverage report over the following week.
If you're building a UI-heavy project on top of this setup, Empire UI pairs well with it. The glassmorphism components and aurora backgrounds have clean SSR output — no hydration mismatches that break your structured data injection. The gradient generator is also handy when you're designing your OG image template and want to get the CSS gradient values right without guessing. SEO plumbing and good-looking pages aren't mutually exclusive — you just need both wired up correctly.
How fast does this all build? With Next.js 15's partial prerendering, static metadata and robots/sitemap files generate at build time in milliseconds. Dynamic OG images render at the edge in 40-100ms typically, depending on font loading. The whole setup adds essentially zero meaningful overhead to your build pipeline.
FAQ
No. next/head does nothing in App Router pages. You need to export metadata or generateMetadata from your page.tsx or layout.tsx files.
Yes. If app/blog/[slug]/opengraph-image.tsx exists, Next.js sets the og:image meta tag automatically — you don't touch the metadata object for this.
Not necessary. A plain object + JSON.stringify in a <script> tag is what Google reads. A typed schema library like schema-dts is nice for autocomplete, but it's optional.
Google largely ignores both fields. Accurate lastModified dates have far more impact on recrawl scheduling.