· seo / i18n / hreflang

Lỗi SEO trong i18n: hreflang và bẫy đồng nhất slug

Một lỗi đánh máy trong tên file âm thầm phá vỡ hreflang graph — không lỗi build, không cảnh báo. Đây là những gì thực sự bị hỏng và cách phát hiện.

Bởi

3.102 từ · 16 phút đọc

Bạn đã implement hreflang đúng theo spec. Tất cả các tag đều có mặt. Lighthouse vượt qua. Ba tuần sau bạn nhận ra Google đang hiển thị bài viết tiếng Anh cho người đọc Việt Nam. Không có error message nào, không có build failure, không có response HTTP 4xx. Chỉ là một lỗi đánh máy trong tên file.

Bài viết này đi sâu vào hai dạng lỗi hreflang chiếm phần lớn trong các vụ tụt ranking âm thầm: bẫy đồng nhất slug và xung đột canonical tag. Chúng tôi dùng codebase Astro v6.3.1 của toolchew làm tài liệu tham khảo — hiện tại đang đúng, và chúng tôi sẽ chỉ ra chính xác điều gì sẽ phá vỡ nó.

Bài này dành cho ai

Các developer đang vận hành site đa ngôn ngữ, đã implement hreflang, và muốn hiểu điều gì có thể âm thầm làm suy giảm nó sau khi ra mắt. Nếu bạn chưa implement hreflang, hãy bắt đầu với spec chính thức của Google trước — hoặc nếu bạn đang dùng Astro, hướng dẫn xây dựng site Astro đa ngôn ngữ của toolchew bao gồm toàn bộ setup kể cả hreflang và routing.

hreflang là gợi ý, không phải lệnh bắt buộc

Sự phân biệt này quan trọng hơn hầu hết developer nghĩ.

Tài liệu chính thức của Google ghi: “Dùng hreflang để cho Google biết về các biến thể nội dung của bạn, để chúng tôi có thể hiểu rằng đây là các phiên bản bản địa hóa của cùng một nội dung.”

Cụm từ then chốt là “để chúng tôi có thể hiểu” — không phải “để chúng tôi sẽ hiển thị.” Google xác nhận vào tháng 5 năm 2025 rằng tín hiệu hreflang được xử lý như gợi ý. Dù implement hoàn hảo, canonical tag, cấu trúc site, độ tương đồng nội dung và bối cảnh người dùng đều có thể ảnh hưởng đến phiên bản nào được hiển thị.

Hai điều Google làm độc lập với hreflang tag của bạn:

Phát hiện ngôn ngữ. Google không dùng hreflang hay thuộc tính HTML lang để phát hiện ngôn ngữ trang. Họ dùng phân tích nội dung của riêng mình. Một trang có nội dung tiếng Anh được đánh dấu hreflang="vi" sẽ không được xử lý như tiếng Việt — nhưng hreflang của nó sẽ bị phá vỡ.

Lập chỉ mục. Một annotation hreflang trỏ đến URL chưa được index không buộc Google phải index URL đó. Nếu các bài viết VI của bạn chưa được index, hreflang trỏ đến chúng sẽ không có tác dụng gì.

Ba quy tắc bắt buộc không thể bỏ qua

Quy tắcYêu cầuHậu quả khi bỏ qua
Tự tham chiếuMỗi trang phải tự liệt kê mình trong hreflang setGoogle có thể bỏ qua toàn bộ set
Hai chiềuNếu /en/ link đến /vi/, thì /vi/ phải link ngược lại /en/Google bỏ qua cả hai annotation
URL đầy đủDùng https://example.com/... — không phải /en/...Tag không hợp lệ và bị bỏ qua âm thầm

Yêu cầu bổ sung: mã ngôn ngữ phải dùng định dạng ISO 639-1 (en, vi). Mã vùng là tùy chọn và phải theo ISO 3166-1 Alpha-2 (en-US, vi-VN). Tag hreflang="x-default" nên có trên mọi trang để xử lý trường hợp không có locale nào khớp với người dùng.

Tại sao lỗi vô hình cho đến khi gây hại

Không có cảnh báo nào trên browser console. Không có build failure. Không có mã lỗi HTTP. Trang vẫn render, hreflang tag vẫn xuất hiện trong <head>, và Google âm thầm bỏ qua set bị hỏng. Báo cáo lỗi hreflang của GSC thường trễ 2–4 tuần so với sự cố thực tế. Bạn có thể không biết cho đến khi đang debug một vụ tụt ranking sáu tuần sau một lần migration.

Bẫy đồng nhất slug

Đây là dạng lỗi bắt gặp những site song ngữ đã implement đúng ban đầu và sau đó bị lệch dần.

Cơ chế của bẫy

Trong apps/site/src/layouts/Base.astro, logic tạo hreflang của toolchew trông như này:

// Base.astro — hreflang generation (Astro v6.3.1, verified 2026-05-29)
const SITE = 'https://toolchew.com';
const hreflangEnHref = articleSlug ? `${SITE}/en/${articleSlug}/` : `${SITE}/en/`;
const hreflangViHref = articleSlug ? `${SITE}/vi/${articleSlug}/` : `${SITE}/vi/`;
const hreflangXDefaultHref = articleSlug ? `${SITE}/en/${articleSlug}/` : `${SITE}/`;

Và trong <head>:

<link rel="canonical" href={canonicalHref} />
<link rel="alternate" href={hreflangEnHref} hreflang="en" />
<link rel="alternate" href={hreflangViHref} hreflang="vi" />
<link rel="alternate" href={hreflangXDefaultHref} hreflang="x-default" />

Code này đúng. Một lệnh curl trực tiếp vào toolchew ngày 2026-05-29 xác nhận điều đó:

# /en/bun-vs-node/ head
<link rel="canonical" href="https://toolchew.com/en/bun-vs-node/">
<link rel="alternate" href="https://toolchew.com/en/bun-vs-node/" hreflang="en">
<link rel="alternate" href="https://toolchew.com/vi/bun-vs-node/" hreflang="vi">
<link rel="alternate" href="https://toolchew.com/en/bun-vs-node/" hreflang="x-default">

# /vi/bun-vs-node/ head
<link rel="canonical" href="https://toolchew.com/vi/bun-vs-node/">
<link rel="alternate" href="https://toolchew.com/en/bun-vs-node/" hreflang="en">
<link rel="alternate" href="https://toolchew.com/vi/bun-vs-node/" hreflang="vi">
<link rel="alternate" href="https://toolchew.com/en/bun-vs-node/" hreflang="x-default">

Canonical tự tham chiếu. hreflang hai chiều. URL tuyệt đối đầy đủ. x-default trên mọi trang. Graph đúng.

Bẫy nằm ở chỗ tiềm ẩn. Code giả định articleSlug giống hệt nhau trong cả thư mục en/vi/. Giả định này đúng lúc này. Nhưng sẽ âm thầm bị phá vỡ ngay khi có một tên file bị lệch.

Trước và sau: sự lệch trông như thế nào

Trạng thái đúng:

en/nextjs-tutorial.md    ←→  vi/nextjs-tutorial.md
/en/nextjs-tutorial/          /vi/nextjs-tutorial/
 ↑ hreflang trỏ đến đây        ↑ hreflang trỏ đến đây

Sau khi tên file VI bị lỗi đánh máy:

en/nextjs-tutorial.md    →   vi/nextjs-tuturial.md  ← lỗi đánh máy trong tên file
/en/nextjs-tutorial/          /vi/nextjs-tuturial/   ← URL VI thực tế
                               /vi/nextjs-tutorial/   ← cái hreflang của EN emit ra (404)

hreflang của trang EN vẫn ghi href="https://toolchew.com/vi/nextjs-tutorial/". URL đó trả về 404. Google crawl nó, không thấy gì, và bỏ qua cặp hreflang cho trang EN. Trang VI tại /vi/nextjs-tuturial/ có hreflang trỏ ngược lại trang EN — đúng về mặt cú pháp — nhưng chiều ngược lại bị hỏng, nên cả hai annotation đều bị bỏ qua âm thầm. Không trang nào nhận được lợi ích nhắm mục tiêu ngôn ngữ.

Không có build error. Astro tạo cả hai trang độc lập từ các file tồn tại. hreflang bị hỏng được emit bởi logic layout của trang EN, vốn chỉ biết slug EN và giả định slug VI khớp theo.

Sự lệch xảy ra như thế nào trong thực tế

Lỗi đánh máy trong tên file là nguyên nhân rõ ràng, nhưng có những nguyên nhân tinh tế hơn:

Bản địa hóa slug có chủ đích. Nếu ai đó quyết định bài VI nên dùng slug tiếng Việt (/vi/cach-su-dung-bun/ so với /en/how-to-use-bun/), giả định đồng nhất slug sẽ thất bại theo thiết kế. Đây là chiến lược bản địa hóa hợp lệ, nhưng đòi hỏi kiến trúc hreflang khác — mỗi trang cần truyền cả hai slug một cách tường minh thay vì giả định tương đồng.

Bài viết mồ côi. Một bài VI bị xóa hoặc đổi tên, nhưng trang EN vẫn có hreflang="vi" trỏ đến URL đã chết. Thường xảy ra sau các đợt kiểm tra nội dung.

Slug được tạo bởi CMS. Nếu bài viết đến từ headless CMS tạo slug từ tiêu đề đã dịch, slug EN và VI sẽ tự nhiên phân kỳ từ đầu.

Phát hiện slug drift bằng curl

# Kiểm tra URL VI mà trang EN tham chiếu
EN_PAGE=https://toolchew.com/en/nextjs-tutorial/
VI_HREF=$(curl -s "$EN_PAGE" | grep -oP 'hreflang="vi"[^>]*href="\K[^"]+')

# Xác minh URL đó thực sự trả về 200
curl -o /dev/null -s -w "%{http_code}" "$VI_HREF"
# 404 = slug drift đã được xác nhận

Chạy lệnh này trên tất cả bài EN của bạn. Nếu URL VI nào trả về status khác 200, bạn có slug drift.

Xung đột canonical tag

hreflang và canonical tag tương tác với nhau, và sự tương tác này mang tính phá hủy khi cấu hình sai.

Google nói rõ: khi canonical tag của một trang trỏ đến URL khác, Google đi theo canonical và bỏ qua các annotation hreflang trên trang đó. Tài liệu của Google về localized versions đề cập đến sự tương tác canonical/hreflang — một canonical cấu hình sai âm thầm làm sụp đổ toàn bộ hreflang graph của bạn.

Mỗi trang bản địa hóa phải có canonical tự tham chiếu — trỏ đến chính nó, không phải phiên bản EN.

Kiểu lỗi phá hủy nhất

Lỗi này thường được đưa vào bởi các SEO plugin coi canonical là công cụ loại trùng, hoặc bởi các developer canonicalize toàn bộ bản dịch về bản gốc EN:

<!-- TRÊN /vi/bun-vs-node/ — BỊ HỎNG -->
<link rel="canonical" href="https://toolchew.com/en/bun-vs-node/"> <!-- ❌ -->
<link rel="alternate" href="https://toolchew.com/vi/bun-vs-node/" hreflang="vi">
<link rel="alternate" href="https://toolchew.com/en/bun-vs-node/" hreflang="en">

Google xử lý như thế này: đi theo canonical đến /en/bun-vs-node/, coi /vi/bun-vs-node/ là bản sao của EN, loại khỏi index, và bỏ qua hoàn toàn annotation hreflang. Người tìm kiếm Việt Nam ở Việt Nam không bao giờ thấy phiên bản tiếng Việt trong kết quả.

Các hreflang tag cú pháp đúng. Lighthouse vượt qua. Cách duy nhất để phát hiện là thực sự kiểm tra canonical tag trên mỗi trang bản địa hóa và xác minh nó trỏ đến URL của trang hiện tại, không phải locale khác.

Tại sao implementation của toolchew tránh được điều này

Logic canonical trong Base.astro:

// canonicalHref mặc định là URL thực tế của trang hiện tại
const canonicalHref = canonical ?? (isRoot ? `${SITE}/en/` : `${SITE}${Astro.url.pathname}`);

Astro.url.pathname luôn phản ánh đúng đường dẫn thực tế của trang hiện tại — /en/bun-vs-node/ trên trang EN, /vi/bun-vs-node/ trên trang VI. Cả hai trang đều nhận canonical tự tham chiếu theo mặc định.

Prop canonical chấp nhận override. Override đó là rủi ro: nếu bất kỳ trang VI nào truyền canonical="/en/some-slug/", nó sẽ tạo ra đúng kiểu xung đột canonical mô tả ở trên. Implementation hiện tại đúng vì không có trang nào làm vậy — nhưng đây là một footgun tiềm ẩn.

Bộ công cụ debug

Curl thủ công (làm trước tiên)

Cách nhanh nhất để xác minh trạng thái hreflang của bất kỳ URL nào mà không cần cài thêm gì:

# Kiểm tra hreflang tag
curl -s https://toolchew.com/en/bun-vs-node/ | grep -i hreflang

# Xác minh URL VI được tham chiếu thực sự tồn tại
VI_HREF=$(curl -s https://toolchew.com/en/bun-vs-node/ | grep -oP 'hreflang="vi"[^>]*href="\K[^"]+')
curl -o /dev/null -s -w "Trạng thái URL VI: %{http_code}\n" "$VI_HREF"

# Xác minh hai chiều: trang VI phải tham chiếu lại EN
curl -s "$VI_HREF" | grep -i hreflang

Kết quả mong đợi từ implementation đúng:

Trạng thái URL VI: 200
<link rel="alternate" href="https://toolchew.com/en/bun-vs-node/" hreflang="en">
<link rel="alternate" href="https://toolchew.com/vi/bun-vs-node/" hreflang="vi">
<link rel="alternate" href="https://toolchew.com/en/bun-vs-node/" hreflang="x-default">

Screaming Frog SEO Spider

Để audit toàn site, Screaming Frog là công cụ tốt nhất hiện có. Bản miễn phí crawl tối đa 500 URL, đủ cho quy mô hiện tại của toolchew (~300 URL bài viết trên cả hai locale). Bản trả phí là €245/năm cho số URL không giới hạn.

Sau khi crawl với Crawl Analysis được bật, tab Hreflang cung cấp 12 bộ lọc. Ba cái liên quan nhất:

Bộ lọcBắt được gì
Non-200 Hreflang URLshreflang trỏ đến 404, redirect, hoặc URL bị chặn — đây là bộ phát hiện bẫy đồng nhất slug
Missing Return Linkshreflang một chiều (yêu cầu hai chiều bị vi phạm)
Non-Canonical Return LinksĐích hreflang có canonical trỏ đến nơi khác — bắt được xung đột canonical liên locale

Để Screaming Frog crawl cả hai locale, vào Configuration → Include và thêm cả toolchew.com/en/toolchew.com/vi/. Nếu không, nó có thể chỉ theo link từ homepage và bỏ sót một locale hoàn toàn.

Export qua Bulk Export → Hreflang → All Hreflang để lấy tất cả annotation trên site trong spreadsheet để kiểm tra.

Screaming Frog không có quan hệ affiliate với toolchew tại thời điểm viết bài — đây là khuyến nghị thẳng thắn. Nếu bạn vượt quá giới hạn 500 URL miễn phí, license trả phí xứng đáng.

Google Search Console

GSC có báo cáo International Targeting. Lưu ý về tình trạng hiện tại: tính năng nhắm mục tiêu theo quốc gia đã bị loại bỏ (Google cho rằng nó “không có nhiều giá trị cho hệ sinh thái”). Tab lỗi hreflang dường như vẫn hoạt động đến tháng 5 năm 2026.

Hai hạn chế quan trọng khiến GSC là công cụ thứ cấp thay vì chính:

Độ trễ. Lỗi hreflang của GSC có thể mất 2–4 tuần để xuất hiện sau một deployment bị hỏng. Lúc lỗi xuất hiện, thiệt hại cho việc index có thể đã xong.

Thiếu phủ sóng. GSC chỉ báo cáo lỗi Google đã crawl và ghi lại. Nếu URL có vấn đề chưa được crawl gần đây, nó sẽ không xuất hiện.

Dùng GSC để xác nhận sự vắng mặt của các loại lỗi đã biết, không phải là công cụ audit toàn diện. Ưu tiên curl + Screaming Frog cho bất cứ thứ gì mang tính vận hành.

Lighthouse

Lighthouse có audit hreflang kiểm tra xem tag có xuất hiện về mặt cú pháp và dùng mã ngôn ngữ hợp lệ không. Nó không phát hiện được link đối nghịch bị hỏng, lỗi đồng nhất slug, hay xung đột canonical. Dùng nó như kiểm tra cú pháp lần đầu sau implementation, rồi chuyển sang các công cụ ở trên để theo dõi sức khỏe thường xuyên.

Implementation tham khảo — Astro v6.3.1 của toolchew

Cấu hình

// astro.config.mjs — cấu hình production của toolchew (2026-05-29)
i18n: {
  defaultLocale: 'en',
  locales: ['en', 'vi'],
  routing: {
    prefixDefaultLocale: true,  // tất cả locale đều có prefix /en/ và /vi/
  },
},

prefixDefaultLocale: true là lựa chọn đúng cho site SEO song ngữ. Nó làm cho cả hai locale đối xứng về mặt cấu trúc: /en/bun-vs-node//vi/bun-vs-node/. Không có URL mặc định trần ở /bun-vs-node/. Sự đối xứng này làm cho việc xây dựng hreflang trở nên đơn giản: đổi phần locale, giữ nguyên phần còn lại. Không có sự mơ hồ về URL EN canonical là gì.

Một điều Astro v6 không tự động làm: tạo hreflang tag. Module i18n routing xử lý cấu trúc URL. Canonical và hreflang meta tag phải được thêm thủ công vào layout — đó là việc Base.astro đang làm. Để so sánh cách routing đa ngôn ngữ của Astro khác Hugo, xem Astro vs Hugo.

Khoảng trống trong sitemap

Integration @astrojs/sitemap hỗ trợ hreflang alternates thông qua option i18n của nó:

// astro.config.mjs — sitemap i18n option (hiện chưa áp dụng cho toolchew)
sitemap({
  i18n: {
    defaultLocale: 'en',
    locales: {
      en: 'en-US',
      vi: 'vi-VN',
    },
  },
  serialize(item) { /* existing priority logic */ },
}),

Sitemap của toolchew hiện không có option này. Sitemap trực tiếp tại toolchew.com/sitemap-0.xml không có <xhtml:link> hreflang alternates. Đây không phải bug — HTML <link> tag trong <head> phục vụ cùng mục đích và là thứ Google chủ yếu dựa vào. Nhưng thêm hreflang vào sitemap cho Google một đường tín hiệu thứ hai để khám phá quan hệ EN/VI, và cách tiếp cận sitemap thực ra an toàn hơn với slug drift: nếu một bài VI bị thiếu, Astro không tạo entry sitemap cho nó, thay vì emit một tham chiếu treo đến một 404 (điều mà cách tiếp cận HTML làm).

Thêm option i18n vào @astrojs/sitemap là thay đổi config hai dòng. Đây là cải tiến hiển nhiên tiếp theo cho cấu hình hreflang của toolchew.

Danh sách kiểm tra / TL;DR

Trước khi deploy

  • Mỗi trang bản địa hóa link đến tất cả phiên bản locale khác trong hreflang set
  • Mỗi trang bản địa hóa có link đến chính nó trong hreflang set
  • Mỗi trang bản địa hóa có canonical tự tham chiếu (không trỏ từ VI về EN)
  • Tất cả giá trị href trong hreflang dùng URL tuyệt đối đầy đủ (https://..., không phải /vi/...)
  • Mã ngôn ngữ dùng định dạng ISO 639-1 (vi, en — không phải viet, không phải UK)
  • Tag hreflang="x-default" tồn tại trên mọi trang
  • Slug EN và slug VI khớp chính xác (hoặc kiến trúc hreflang của bạn dùng tham chiếu cross-locale tường minh, không giả định tương đồng)

Sau khi deploy

  • Chạy curl -s [URL] | grep hreflang trên cả phiên bản EN và VI của ít nhất 3 bài viết
  • Xác minh URL VI được tham chiếu trong hreflang của EN thực sự trả về HTTP 200
  • Xác minh URL EN được tham chiếu trong hreflang của VI thực sự trả về HTTP 200
  • Chạy Screaming Frog trên toàn site; kiểm tra “Non-200 Hreflang URLs” và “Missing Return Links”
  • Kiểm tra GSC International Targeting → tab Hreflang cho lỗi (có thể trễ 2–4 tuần)

Định kỳ (hàng quý)

  • Audit sau mỗi lần migration nội dung, thay đổi CMS, cơ cấu lại URL, hoặc dịch hàng loạt
  • Sau khi thêm bài viết mới, xác minh file VI đối chiếu tồn tại trước khi deploy

Lưu ý

Implementation hreflang của toolchew đúng tại thời điểm 2026-05-29 (xác minh qua curl trực tiếp). Bài viết này dùng nó làm tài liệu tham khảo, không phải bằng chứng của sự hoàn hảo — rủi ro đồng nhất slug tiềm ẩn là có thật và sẽ kích hoạt ngay khi một tên file VI bị lệch.

Phạm vi hiện tại của báo cáo International Targeting trong GSC còn mơ hồ sau khi tính năng nhắm mục tiêu theo quốc gia bị loại bỏ. Bằng chứng từ cộng đồng cho thấy tab lỗi hreflang vẫn còn hoạt động, nhưng đừng coi nó là công cụ chính.

Screaming Frog, Semrush và Ahrefs là các khuyến nghị thẳng thắn. toolchew không có quan hệ affiliate đã xác nhận với bất kỳ công cụ nào trong số này tại thời điểm viết bài.

Tài liệu tham khảo