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ống | Dùng | Ví dụ |
|---|---|---|
| Page có metadata cố định | export const metadata | Homepage, About, Blog landing |
| Page có metadata từ dữ liệu | generateMetadata() | Blog post, Topic page, Pillar page |
| Layout cần set defaults | export const metadata | Root 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ế:
| Page | Khai báo | HTML <title> |
|---|---|---|
| Homepage | không (dùng default) | "Le Duy Khuong" |
| About | title: "About" | "About | Le Duy Khuong" |
| Blog post | title: "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:
- Page metadata override layout metadata cho cùng field
- Layout metadata là baseline — page chỉ cần khai báo những gì khác
- 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 và /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 helper — lib/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.tsxCâ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.htmlVerify: 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.tsxCâ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.