Le Duy Khuong

Chuỗi: seo-nextjs-metadata · Phần 1

Năng suất & công cụ dev

Next.js Metadata API — Nền tảng

Static vs dynamic metadata, title template, cascade, canonical URLs

2026-03-206 phút đọcVI

Next.js Metadata API — Nền tảng

Mở đầu

Khi anh deploy leduykhuong.com, mọi thẻ SEO trong <head> — title, description, canonical URL, Open Graph — đều được tạo bởi Next.js Metadata API. Đây không phải là thư viện bên ngoài mà là tính năng built-in của Next.js từ phiên bản 13 App Router trở đi.

Tại sao cần hiểu? Vì nếu metadata sai, Google hiển thị title sai trên kết quả tìm kiếm, LinkedIn preview thiếu ảnh, và Lighthouse SEO score tụt. Metadata API là "backbone" — tất cả SEO output đều đi qua đây.

Mục tiêu bài này: Sau khi đọc xong, anh sẽ hiểu cách Next.js tạo metadata, sự khác biệt giữa static và dynamic metadata, cơ chế cascade từ layout xuống page, và cách leduykhuong.com đang dùng pattern này.


Static Metadata vs Dynamic Metadata

Next.js cung cấp hai cách khai báo metadata:

Static metadata — export metadata object

Khi metadata không phụ thuộc vào dữ liệu runtime (ví dụ: homepage, about page), anh export một object tĩnh:

// app/layout.tsx — leduykhuong.com root layout
export const metadata: Metadata = {
  title: { default: "Le Duy Khuong", template: "%s | Le Duy Khuong" },
  description:
    "Engineering Leader × AI/Agentic Systems Builder — leduykhuong.com",
  openGraph: {
    title: "Le Duy Khuong",
    description:
      "Engineering Leader × AI/Agentic Systems Builder — leduykhuong.com",
    siteName: "Le Duy Khuong",
  },
};

Object này được Next.js đọc tại build time và render thành các thẻ <meta> trong HTML output. Không có code nào chạy ở runtime — hoàn toàn static.

Dynamic metadata — generateMetadata() function

Khi metadata phụ thuộc vào dữ liệu (ví dụ: blog post title lấy từ MDX frontmatter), anh dùng async function:

// app/[locale]/blog/[slug]/page.tsx — leduykhuong.com blog post
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { locale: localeParam, slug } = await params;
  const locale = parseLocale(localeParam);
  const post = getPostBySlug(slug);
  if (!post) return { title: "Not Found" };
 
  const title = post.seoTitle || getDisplayTitle(post, locale);
  const description = getSeoDescription(post) || getDisplayExcerpt(post, locale);
 
  return {
    title,
    description,
    openGraph: getOgMetadata(post),
    twitter: getTwitterMetadata(post),
    alternates: {
      canonical: `${BASE_URL}/${locale}/blog/${slug}`,
      languages: {
        vi: `${BASE_URL}/vi/blog/${slug}`,
        en: `${BASE_URL}/en/blog/${slug}`,
      },
    },
  };
}

Khác biệt quan trọng: generateMetadata() nhận params (route parameters) và có thể đọc data (gọi getPostBySlug). Next.js gọi function này cho mỗi page tại build time (static export) hoặc request time (server rendering).

Khi nào dùng gì?

Tình huốngDùngVí dụ
Page có metadata cố địnhexport const metadataHomepage, About, Blog landing
Page có metadata từ dữ liệugenerateMetadata()Blog post, Topic page, Pillar page
Layout cần set defaultsexport const metadataRoot layout (title template)

Quy tắc: Ưu tiên static khi có thể — đơn giản hơn, dễ đọc hơn, không có risk lỗi runtime.


Title Template — Pattern %s | Site Name

Một trong những pattern mạnh nhất của Metadata API là title template. Trong root layout:

export const metadata: Metadata = {
  title: { default: "Le Duy Khuong", template: "%s | Le Duy Khuong" },
};
  • default: hiển thị khi page KHÔNG khai báo title riêng → "Le Duy Khuong"
  • template: khi page CON khai báo title, %s được thay bằng title đó

Ví dụ thực tế:

PageKhai báoHTML <title>
Homepagekhông (dùng default)"Le Duy Khuong"
Abouttitle: "About""About | Le Duy Khuong"
Blog posttitle: "Learning in Public""Learning in Public | Le Duy Khuong"

Template KHÔNG áp dụng cho default value — chỉ áp dụng khi page con override title. Điều này tránh "Le Duy Khuong | Le Duy Khuong" trên homepage.


Metadata Cascade — Layout → Page

Next.js merge metadata theo cây component: root layout → nested layout → page. Rules:

  1. Page metadata override layout metadata cho cùng field
  2. Layout metadata là baseline — page chỉ cần khai báo những gì khác
  3. Nested objects merge (openGraph, twitter) — page fields override layout fields

Ví dụ trên leduykhuong.com:

app/layout.tsx (root)
├── title.template: "%s | Le Duy Khuong"
├── description: "Engineering Leader..."
├── openGraph.siteName: "Le Duy Khuong"
│
└── app/[locale]/blog/[slug]/page.tsx (blog post)
    ├── title: "Learning in Public"       ← override (template applied)
    ├── description: "Tại sao viết..."    ← override
    ├── openGraph: getOgMetadata(post)    ← override entirely
    └── alternates.canonical: "https://..." ← new field

Kết quả HTML:

<title>Learning in Public | Le Duy Khuong</title>
<meta name="description" content="Tại sao viết giúp suy nghĩ rõ hơn..." />
<meta property="og:site_name" content="Le Duy Khuong" />
<!-- ...full OG tags from getOgMetadata()... -->
<link rel="canonical" href="https://leduykhuong.com/vi/blog/learning-in-public" />

Tại sao quan trọng: Cascade cho phép anh set defaults một lần trong layout (siteName, RSS link, GSC verification) mà không cần lặp lại ở mỗi page. DRY principle cho SEO.


Canonical URL — alternates.canonical

Canonical URL nói cho Google: "Đây là URL chính thức cho trang này." Nếu cùng nội dung có nhiều URL (ví dụ: /vi/blog/slug/en/blog/slug), canonical chỉ định URL nào Google nên index.

Trong Next.js Metadata API:

return {
  alternates: {
    canonical: `${BASE_URL}/${locale}/blog/${slug}`,
    languages: {
      vi: `${BASE_URL}/vi/blog/${slug}`,
      en: `${BASE_URL}/en/blog/${slug}`,
      "x-default": `${BASE_URL}/vi/blog/${slug}`,
    },
  },
};

Next.js render ra:

<link rel="canonical" href="https://leduykhuong.com/vi/blog/learning-in-public" />
<link rel="alternate" hreflang="vi" href="https://leduykhuong.com/vi/blog/learning-in-public" />
<link rel="alternate" hreflang="en" href="https://leduykhuong.com/en/blog/learning-in-public" />
<link rel="alternate" hreflang="x-default" href="https://leduykhuong.com/vi/blog/learning-in-public" />

Centralized helperlib/seo.ts:

export function getCanonicalUrl(path: string): string {
  const clean = path.replace(/^\/(en|vi)/, "");
  return `${SITE_CONFIG.baseUrl}${clean || "/"}`;
}

Function này strip locale prefix vì leduykhuong.com dùng client-side language switching — canonical trỏ tới URL không có locale (fallback).


Conditional Metadata — Env Vars

Đôi khi metadata phụ thuộc vào environment variable (ví dụ: Google Search Console verification chỉ có trên production). Pattern:

export const metadata: Metadata = {
  // ...
  ...(process.env.NEXT_PUBLIC_GSC_VERIFICATION
    ? {
        verification: {
          google: process.env.NEXT_PUBLIC_GSC_VERIFICATION,
        },
      }
    : {}),
};

Spread conditional: ...(condition ? { fields } : {}) — thêm fields khi env var tồn tại, không thêm gì khi không có. Next.js render:

<!-- Khi NEXT_PUBLIC_GSC_VERIFICATION có giá trị -->
<meta name="google-site-verification" content="abc123..." />
 
<!-- Khi không có → KHÔNG render thẻ này -->

Pattern này giữ code sạch mà không cần if/else phức tạp. leduykhuong.com dùng cho GSC verification và Cloudflare Analytics token.


Thực hành

Bài tập 1: Đọc metadata cascade

Mở 2 file và so sánh:

# Root layout metadata
grep -A 15 "export const metadata" app/layout.tsx
 
# Blog post dynamic metadata
grep -A 20 "generateMetadata" app/[locale]/blog/[slug]/page.tsx

Câu hỏi: Field nào từ layout bị override bởi blog post? Field nào được giữ?

Bài tập 2: Verify HTML output

# Build site
npm run build
 
# Check title tag
grep "<title>" out/vi/blog/learning-in-public.html
 
# Check canonical
grep 'rel="canonical"' out/vi/blog/learning-in-public.html
 
# Check description
grep 'name="description"' out/vi/blog/learning-in-public.html

Verify: title có format "Post Title | Le Duy Khuong"? canonical đúng URL?

Bài tập 3: Tìm metadata trên About page

grep -B2 -A10 "metadata" app/[locale]/about/page.tsx

Câu hỏi: About page dùng static hay dynamic metadata? Tại sao?


Tóm tắt

  • Static export const metadata — dùng cho pages có metadata cố định (homepage, about)
  • Dynamic generateMetadata() — dùng khi metadata phụ thuộc dữ liệu (blog posts)
  • Title template %s | Site Name — set 1 lần trong layout, tự động apply cho tất cả pages
  • Cascade layout → page — page override layout, không cần lặp lại defaults
  • alternates.canonical — Next.js render <link rel="canonical"> tự động
  • Conditional metadata dùng spread operator ...(condition ? {} : {}) cho env-dependent fields

Bài tiếp theo

Bài 2: Open Graph & Twitter Cards trong Next.js — Hiểu cách Next.js render OG và Twitter Card meta tags, tại sao image phải 1200x630, và cách debug social previews bằng Facebook Debugger.

LDK

Le Duy Khuong

AI Transformation & Digital Strategy. Writing about agentic systems, engineering leadership, and building in public.