Bỏ qua để vào nội dung chính
Technical SEO for Next.js App Router: Structured Data, hreflang, ISR, and Sitemaps

Technical SEO for Next.js App Router: Structured Data, hreflang, ISR, and Sitemaps

Bởi Marcus Bennett
31 thg 3, 202610 phút đọc

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 metadata object from a page.tsx or layout.tsx
  • Dynamic metadata — export an async generateMetadata function 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=2 vs /blog/page/2)
  • Trailing slash variations (/blog/post vs /blog/post/)
  • UTM-tagged URLs from marketing campaigns
  • Locale-prefixed routes where /en/blog/post and /blog/post coexist
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 */}
    </>
  )
}
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-default fallback 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 generateMetadata for dynamic pages; set title.template in root layout.tsx
  • Canonical URLs: Always set alternates.canonical with fully qualified absolute URLs on every page
  • Structured Data: Add Article + BreadcrumbList on every post; FAQPage schema where applicable
  • hreflang: Set alternates.languages including x-default on every localized route
  • Sitemap: Use app/sitemap.ts with alternates.languages per 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.tsx per 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.

Không spam, hủy đăng ký bất kỳ lúc nào.

Bài viết liên quan