Technical SEO for Next.js App Router: Structured Data, hreflang, ISR, and Sitemaps
A comprehensive deep dive into technical SEO for Next.js App Router — covering the Metadata API, JSON-LD structured data (Article, BreadcrumbList, FAQ), hreflang with alternates, dynamic sitemaps, ISR vs SSG trade-offs, robots.txt, canonical URLs, and dynamic OG images with next/og.
Introduction: Why Technical SEO Matters More in the App Router Era
Next.js 13+ App Router rewrote the rules for how we build React applications — and with it, the patterns for technical SEO shifted significantly. The old Pages Router relied on next/head and manual meta tag management. Today, the App Router gives us a first-class Metadata API, file-based conventions for sitemaps and robots.txt, and seamless integration with React Server Components — all of which have direct implications for how search engines crawl and index your content.
This deep dive covers everything you need to nail technical SEO in a Next.js App Router project: the Metadata API, JSON-LD structured data, hreflang for international sites, ISR vs SSG trade-offs, dynamic sitemaps, canonical URLs, and dynamic OG images.
The Next.js Metadata API
The App Router introduces two ways to define metadata for a page:
- Static metadata — export a
metadataobject from apage.tsxorlayout.tsx - Dynamic metadata — export an async
generateMetadatafunction that can fetch data at request time
// app/blog/[slug]/page.tsx
import { Metadata } from 'next'
// Static metadata (simple pages)
export const metadata: Metadata = {
title: 'My Blog Post',
description: 'A deep dive into technical SEO with Next.js App Router',
}
// Dynamic metadata (data-driven pages)
export async function generateMetadata({
params,
}: {
params: { slug: string }
}): Promise<Metadata> {
const post = await fetchPost(params.slug)
return {
title: post.title,
description: post.summary,
alternates: {
canonical: `https://nextfuture.io.vn/blog/${post.slug}`,
},
openGraph: {
title: post.title,
description: post.summary,
url: `https://nextfuture.io.vn/blog/${post.slug}`,
type: 'article',
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
authors: ['NextFuture Team'],
siteName: 'NextFuture',
},
}
}
Next.js merges metadata from parent layouts into child pages. Define shared metadata like openGraph.siteName and title.template in your root layout.tsx, then override specifics in leaf pages — eliminating repetition across hundreds of routes.
// app/layout.tsx
export const metadata: Metadata = {
title: {
template: '%s | NextFuture',
default: 'NextFuture — AI & Frontend Engineering',
},
openGraph: {
siteName: 'NextFuture',
locale: 'en_US',
},
}
Canonical URLs: Preventing Duplicate Content
Canonical URLs tell search engines which URL is the authoritative version of a page when multiple URLs serve the same content. This matters for:
- Paginated content (
/blog?page=2vs/blog/page/2) - Trailing slash variations (
/blog/postvs/blog/post/) - UTM-tagged URLs from marketing campaigns
- Locale-prefixed routes where
/en/blog/postand/blog/postcoexist
export async function generateMetadata({ params }): Promise<Metadata> {
return {
alternates: {
canonical: `https://nextfuture.io.vn/blog/${params.slug}`,
},
}
}
Next.js renders this as <link rel="canonical" href="..." /> in the document head. Always use fully qualified absolute URLs — relative canonicals are technically valid but problematic in practice.
JSON-LD Structured Data
Structured data helps search engines understand your content semantically, not just lexically. Google uses it to generate rich results — article bylines, FAQ dropdowns, breadcrumb trails in search snippets. The recommended format is JSON-LD, injected via a <script> tag inside your Server Component.
Article Schema
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }) {
const post = await fetchPost(params.slug)
const articleJsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
description: post.summary,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
'@type': 'Person',
name: post.author.name,
url: `https://nextfuture.io.vn/authors/${post.author.slug}`,
},
publisher: {
'@type': 'Organization',
name: 'NextFuture',
logo: {
'@type': 'ImageObject',
url: 'https://nextfuture.io.vn/logo.png',
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `https://nextfuture.io.vn/blog/${post.slug}`,
},
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }}
/>
{/* rest of your page */}
</>
)
}
BreadcrumbList Schema
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Home',
item: 'https://nextfuture.io.vn',
},
{
'@type': 'ListItem',
position: 2,
name: 'Blog',
item: 'https://nextfuture.io.vn/blog',
},
{
'@type': 'ListItem',
position: 3,
name: post.title,
item: `https://nextfuture.io.vn/blog/${post.slug}`,
},
],
}
FAQPage Schema
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: post.faqs.map((faq) => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer,
},
})),
}
You can render multiple JSON-LD blocks on the same page by including several <script type="application/ld+json"> tags. Since these live in Server Components, they add zero JavaScript to the client bundle.
hreflang: International SEO with App Router
If your site serves multiple languages or regions, hreflang tells Google which version to serve to which audience — preventing your English and Vietnamese content from competing against each other. The App Router's alternates.languages metadata key handles this cleanly.
// app/[locale]/blog/[slug]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
const { locale, slug } = params
const post = await fetchPost(slug, locale)
return {
alternates: {
canonical: `https://nextfuture.io.vn/${locale}/blog/${slug}`,
languages: {
'en': `https://nextfuture.io.vn/en/blog/${slug}`,
'vi': `https://nextfuture.io.vn/vi/blog/${slug}`,
'x-default': `https://nextfuture.io.vn/en/blog/${slug}`,
},
},
}
}
Next.js automatically generates the corresponding <link rel="alternate" hreflang="..." href="..." /> tags. Critical rules:
- Every page in the alternate set must reference all other pages in the set, including itself
- Always include an
x-defaultfallback for users whose language has no dedicated version - Use fully qualified absolute URLs (protocol + domain + path)
- hreflang values must be valid BCP 47 language tags:
en,en-US,vi,zh-TW
Sitemaps with sitemap.ts and Locale Alternates
The App Router's file convention app/sitemap.ts generates a standards-compliant XML sitemap at /sitemap.xml. For multilingual sites, you can include alternates per URL entry to embed xhtml:link alternate references directly in the sitemap.
// app/sitemap.ts
import { MetadataRoute } from 'next'
const BASE_URL = 'https://nextfuture.io.vn'
const LOCALES = ['en', 'vi'] as const
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await fetchAllPublishedPosts()
const postEntries = posts.flatMap((post) =>
LOCALES.map((locale) => ({
url: `${BASE_URL}/${locale}/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: 'weekly' as const,
priority: 0.8,
alternates: {
languages: Object.fromEntries(
LOCALES.map((l) => [l, `${BASE_URL}/${l}/blog/${post.slug}`])
),
},
}))
)
const staticPages = [
{ url: BASE_URL, changeFrequency: 'daily' as const, priority: 1 },
{ url: `${BASE_URL}/blog`, changeFrequency: 'daily' as const, priority: 0.9 },
]
return [...staticPages, ...postEntries]
}
For large sites (10,000+ URLs), Next.js supports sitemap splitting via generateSitemaps() — each function call returns a chunk, and Next.js wires up a sitemap index automatically.
robots.txt
Create app/robots.ts to generate a dynamic robots.txt at /robots.txt:
// app/robots.ts
import { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/admin/', '/dashboard/', '/_next/'],
},
{
userAgent: 'GPTBot',
disallow: '/',
},
],
sitemap: 'https://nextfuture.io.vn/sitemap.xml',
host: 'https://nextfuture.io.vn',
}
}
The example above also demonstrates blocking AI crawlers (like GPTBot) — a common production requirement. The host directive signals the canonical host to prevent crawl budget dilution across subdomains.
ISR vs SSG: The SEO Trade-off
One of the most consequential architectural decisions for an SEO-optimized Next.js site is choosing between Static Site Generation (SSG) and Incremental Static Regeneration (ISR). The wrong choice costs either freshness or performance.
When to Use SSG
Full SSG is ideal when content rarely changes and build time is acceptable:
- Documentation sites and evergreen tutorials
- Marketing landing pages
- Content that follows a predictable publish schedule
// Fully static — built at deploy time, never server-rendered
export async function generateStaticParams() {
const posts = await fetchAllPosts()
return posts.map((post) => ({ slug: post.slug }))
}
export const dynamic = 'force-static'
When to Use ISR
ISR combines static performance with content freshness — ideal for:
- News and blog posts updated regularly
- E-commerce product pages with changing prices or stock levels
- Pages pulling from a headless CMS where editors publish frequently
// Revalidate every 3600 seconds (1 hour)
export const revalidate = 3600
export default async function BlogPost({ params }) {
const post = await fetchPost(params.slug)
// This page serves from cache and regenerates in the background
}
SEO Tip: Googlebot does not re-crawl more frequently just because your ISR interval is short. It crawls on its own schedule — typically every few days for established pages. A 1–6 hour revalidation window is sufficient for most blog content, and a longer window reduces server-side compute costs significantly.
On-Demand Revalidation: The Best of Both
For CMS-driven content, on-demand revalidation via webhook is superior to time-based ISR — pages refresh immediately when content is updated, not on a polling interval:
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'
export async function POST(req: NextRequest) {
const { secret, slug, type } = await req.json()
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
if (type === 'post') {
revalidatePath(`/blog/${slug}`)
revalidatePath('/blog') // invalidate listing page too
revalidateTag('posts')
}
return Response.json({ revalidated: true, timestamp: Date.now() })
}
Pair this endpoint with a CMS webhook (Sanity, Contentful, Strapi) to get near-instant SEO freshness without burning ISR compute budget on idle pages.
Dynamic OG Images with next/og
Open Graph images significantly increase click-through rates on social and messaging platforms. Next.js provides the ImageResponse API (via next/og) to generate them dynamically at the Edge — no canvas, no Puppeteer, no headless Chrome.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
export const runtime = 'edge'
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={{
background: 'linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 100%)',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
padding: '60px',
fontFamily: 'Inter, sans-serif',
}}
>
<div style={{ fontSize: 14, color: '#6366f1', marginBottom: 16, textTransform: 'uppercase', letterSpacing: 3 }}>
{post.category}
</div>
<div style={{ fontSize: 48, fontWeight: 700, color: '#ffffff', lineHeight: 1.2, marginBottom: 24 }}>
{post.title}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ fontSize: 18, color: '#94a3b8' }}>nextfuture.io.vn</div>
</div>
</div>
),
{ ...size }
)
}
The file convention opengraph-image.tsx automatically generates the og:image meta tag for that route segment. Next.js also supports twitter-image.tsx for Twitter Card images. Both are generated per-route — each blog post gets a unique, branded image with zero extra configuration in your metadata exports.
Putting It All Together: A Production Checklist
Here is a consolidated technical SEO checklist for your Next.js App Router project:
- Metadata API: Use
generateMetadatafor dynamic pages; settitle.templatein rootlayout.tsx - Canonical URLs: Always set
alternates.canonicalwith fully qualified absolute URLs on every page - Structured Data: Add Article + BreadcrumbList on every post; FAQPage schema where applicable
- hreflang: Set
alternates.languagesincludingx-defaulton every localized route - Sitemap: Use
app/sitemap.tswithalternates.languagesper URL entry for multilingual sites - robots.txt: Use
app/robots.ts; block/api/,/admin/, and crawler-specific rules - Rendering strategy: SSG for evergreen content; ISR + on-demand revalidation for CMS-driven content
- OG images: Use
opengraph-image.tsxper route segment for dynamic, branded social images - Core Web Vitals: Monitor LCP, CLS, INP — static and ISR pages have a structural performance advantage over dynamic rendering
Conclusion
The Next.js App Router is a genuine upgrade for technical SEO — not just because it is fast, but because it makes the right thing the easy thing. File-based conventions for sitemaps and robots.txt eliminate infrastructure boilerplate. The Metadata API's alternates key makes hreflang and canonicals declarative rather than imperative. Server Components let you inject JSON-LD without adding JavaScript to the client bundle. And next/og turns OG image generation into a ten-minute task instead of a DevOps project.
The key insight is that good SEO in Next.js App Router is mostly just using the framework correctly. The primitives are there — generateMetadata, sitemap.ts, robots.ts, opengraph-image.tsx, and the cache revalidation APIs. Wire them up intentionally from day one, and you will have a technical SEO foundation that scales from a personal blog to a high-traffic publication without ever needing to bolt on a third-party SEO library.
Admin
Cal.com
Open source scheduling — self-host your booking system, replace Calendly. Free & privacy-first.
Comments (0)
Sign in to comment
No comments yet. Be the first to comment!