2026-03-205 phút đọcVI
Canonical URLs & Duplicate Content
Mở đầu
leduykhuong.com có mỗi bài blog ở 2 URLs: /vi/blog/slug và /en/blog/slug. Google thấy 2 pages cùng content → duplicate content issue. Nếu không xử lý, Google phải tự chọn version nào index — và có thể chọn sai, hoặc split ranking signals giữa 2 versions.
Canonical URL giải quyết bằng cách nói rõ: "URL chính thức cho content này là X. Các URL khác là alternate versions."
Mục tiêu: Hiểu duplicate content problem, canonical URL implementation trên leduykhuong.com, hreflang cho multilingual, và edge cases.
Duplicate Content — Vấn đề gì?
Google không "phạt" duplicate content (trừ spam). Nhưng nó tạo vấn đề:
-
Ranking signal dilution — Backlinks tới
/vi/blog/slugvà/en/blog/slugbị split. Thay vì 1 URL có 10 backlinks, có 2 URLs mỗi URL 5 backlinks. -
Wrong version indexed — Google có thể chọn index
/en/blog/slugcho Vietnamese users nếu Google coi en version là "better." -
Crawl waste — Google crawl cả 2 versions → sử dụng crawl budget cho duplicate content.
Sources of Duplicates trên leduykhuong.com
| Source | URLs | Duplicate? |
|---|---|---|
| Locale variants | /vi/blog/slug + /en/blog/slug | Có (cùng content, khác locale) |
| With/without trailing slash | /blog/ + /blog | Cloudflare Pages handles |
| HTTP vs HTTPS | http:// + https:// | Cloudflare auto-redirect |
| www vs non-www | www.leduykhuong.com + leduykhuong.com | DNS config |
Canonical URL — Solution
<link rel="canonical">
<link rel="canonical" href="https://leduykhuong.com/vi/blog/learning-in-public" />Nói Google: "URL chính thức cho page này là https://leduykhuong.com/vi/blog/learning-in-public."
Implementation trên leduykhuong.com
// app/[locale]/blog/[slug]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale: localeParam, slug } = await params;
const locale = parseLocale(localeParam);
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}`,
},
},
};
}HTML Output
Cho /vi/blog/learning-in-public:
<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" />Cho /en/blog/learning-in-public:
<link rel="canonical" href="https://leduykhuong.com/en/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" />Key points:
- Mỗi locale version có canonical tự trỏ tới chính nó (self-referencing canonical)
- hreflang links bidirectional — vi page link tới en, en page link tới vi
x-defaulttrỏ tới vi version (default language)
hreflang — Giải thích cho Google
hreflang nói Google: "Page này có version khác cho locale khác."
Format
hreflang="vi" → Vietnamese version
hreflang="en" → English version
hreflang="x-default" → Default/fallback version
Google behavior
Khi Vietnamese user search:
- Google thấy
hreflang="vi"→ ưu tiên hiển thị/vi/blog/slug - Ranking signals từ cả vi và en consolidated
Khi English user search:
- Google thấy
hreflang="en"→ ưu tiên hiển thị/en/blog/slug
x-default
x-default = fallback cho locales không được list. Nếu French user search → Google dùng x-default version (vi trong trường hợp leduykhuong.com).
Self-Referencing Canonical — Tại sao?
Tại sao vi page có canonical trỏ tới chính nó?
<!-- /vi/blog/slug → canonical = /vi/blog/slug -->
<link rel="canonical" href="https://leduykhuong.com/vi/blog/learning-in-public" />3 lý do:
-
Explicit declaration — Google biết chắc anh chủ đích muốn URL này là canonical. Không đoán.
-
Prevent parameter issues — Nếu ai link tới
?utm_source=twitter, canonical nói rõ version sạch. -
Google best practice — Google khuyến nghị mọi page có self-referencing canonical.
Edge Cases & Pitfalls
1. Canonical conflict với hreflang
<!-- ❌ SAI: canonical trỏ tới vi, nhưng đây là en page -->
<!-- /en/blog/slug -->
<link rel="canonical" href="https://leduykhuong.com/vi/blog/learning-in-public" />Nếu en page có canonical trỏ tới vi page → Google coi en page là duplicate → KHÔNG index en version → English users không tìm thấy.
Correct: Mỗi locale version canonical trỏ tới chính nó.
2. Missing hreflang on one side
<!-- /vi/blog/slug có hreflang tới en -->
<link rel="alternate" hreflang="en" href=".../en/blog/slug" />
<!-- NHƯNG /en/blog/slug KHÔNG có hreflang tới vi -->Google cần bidirectional confirmation — cả 2 pages phải link tới nhau. Missing one direction → Google ignore hreflang.
leduykhuong.com: generateMetadata() chạy cho CẢ HAI locales → luôn bidirectional. ✅
3. Canonical 404
<!-- Canonical URL trả về 404 -->
<link rel="canonical" href="https://leduykhuong.com/vi/blog/deleted-post" />Google fetch canonical → 404 → confusion. Always verify canonical URL tồn tại.
getCanonicalUrl() — Helper function
// lib/seo.ts
export function getCanonicalUrl(path: string): string {
const clean = path.replace(/^\/(en|vi)/, "");
return `${SITE_CONFIG.baseUrl}${clean || "/"}`;
}Lưu ý: Function này strip locale prefix. Nó được dùng cho JSON-LD url field (locale-independent canonical). Blog post generateMetadata() dùng locale-specific canonical (${BASE_URL}/${locale}/blog/${slug}).
2 canonical strategies đang coexist:
- HTML
<link rel="canonical">→ locale-specific (mỗi locale tự trỏ) - JSON-LD
url→ locale-independent (strip locale)
Điều này acceptable vì Google dùng HTML canonical cho indexing decisions, JSON-LD url cho structured data.
Verification Workflow
Sau deploy, verify canonical setup:
# 1. Check vi page canonical
curl -s https://leduykhuong.com/vi/blog/learning-in-public | \
grep 'rel="canonical"'
# 2. Check en page canonical (should be different)
curl -s https://leduykhuong.com/en/blog/learning-in-public | \
grep 'rel="canonical"'
# 3. Check hreflang bidirectional
curl -s https://leduykhuong.com/vi/blog/learning-in-public | \
grep 'hreflang'
# 4. GSC URL Inspection → both URLs should be "Indexed"Thực hành
Bài tập 1: Verify canonicals
cd ACE-component/ACE-leduykhuong-site && npm run build
# Compare vi vs en canonical
grep 'rel="canonical"' out/vi/blog/learning-in-public.html
grep 'rel="canonical"' out/en/blog/learning-in-public.htmlCâu hỏi: Canonical khác nhau? Mỗi locale tự trỏ?
Bài tập 2: Check hreflang bidirectional
# vi page hreflang
grep 'hreflang' out/vi/blog/learning-in-public.html
# en page hreflang
grep 'hreflang' out/en/blog/learning-in-public.htmlCâu hỏi: Cả 2 có hreflang tới nhau? x-default trỏ đúng?
Bài tập 3: GSC duplicate check
GSC → Pages → filter "Duplicate":
- Có pages nào bị "Duplicate, Google chose different canonical"?
- Nếu có, canonical nào Google chọn? Match với intent?
Tóm tắt
- Duplicate content — Multilingual sites có inherent risk. Không bị phạt nhưng dilute ranking.
- Canonical URL — Self-referencing per locale. Mỗi locale page trỏ canonical tới chính nó.
- hreflang — Bidirectional relationship. Cả 2 locales phải link tới nhau.
x-default— Fallback cho unmatched locales. leduykhuong.com → vi.- Pitfalls: Canonical cross-locale (sai), missing bidirectional (invalid), canonical 404 (broken).
- Verify sau deploy bằng curl + GSC URL Inspection.
Bài tiếp theo
Kết thúc series Technical SEO. Series cuối: Cloudflare Pages — Bài 20 sẽ giới thiệu Cloudflare Pages architecture, tại sao leduykhuong.com chọn CF Pages, và cách nó ảnh hưởng tới SEO/analytics.