Làm sao để Google thấy đủ site hai ngôn ngữ của bạn (Next.js)

Site hai ngôn ngữ vẫn có thể vô hình với Google nếu thiếu vài dòng nhỏ. Cách khai báo địa chỉ chính, cặp ngôn ngữ, sơ đồ trang và dữ liệu có cấu trúc, cùng lỗi trang gốc tự chuyển ngôn ngữ.

Phát LâmĐăng Cập nhật 8 phút đọcTrung cấp
Làm sao để Google thấy đủ site hai ngôn ngữ của bạn (Next.js)
Mục lục
  1. Google nhìn site của bạn thế nào
  2. Địa chỉ chính của từng trang (canonical)
  3. Nói cho Google biết bản tiếng Việt và bản tiếng Anh là một cặp (hreflang)
  4. Bản đồ trang cho robot (sitemap)
  5. Giới thiệu bản thân cho Google bằng dữ liệu có cấu trúc (JSON-LD)
  6. Xác minh Search Console và favicon
  7. Lỗi kinh điển: trang gốc tự chuyển ngôn ngữ
  8. Mình đã kiểm gì
  9. Làm gì tiếp theo

Bạn làm xong một site hai ngôn ngữ, đẹp, chạy nhanh. Vài tuần sau gõ tên mình lên Google thì chỉ thấy bản tiếng Anh, hoặc không thấy gì. Lỗi thường không nằm ở nội dung. Nó nằm ở những dòng nhỏ trong phần đầu của trang mà người xem không bao giờ nhìn thấy, như thiếu thẻ nói "đây là địa chỉ chính", hai bản ngôn ngữ không trỏ vào nhau, hay trang chủ tự đẩy mọi người sang một ngôn ngữ.

Bài này là danh sách việc mình làm cho một site song ngữ vi/en viết bằng Next.js (App Router). Ví dụ dùng Next.js 16.4 trong một dự án tạm, nhưng các tính năng metadata này chạy tương tự từ Next 14. Khác biệt chính là ở bản mới params là Promise nên phải await.

Google nhìn site của bạn thế nào

Con robot của Google (Googlebot) tải HTML về, đọc phần <head>, đi theo các liên kết, rồi quyết định trang nào được lưu và trang nào là bản "chính". Nó không có tài khoản, không có cookie, và không gửi thông tin ngôn ngữ trình duyệt như người thật. Vì vậy mọi thứ quan trọng phải nằm ngay trong HTML mà máy chủ trả về cho một người lạ vô danh.

Địa chỉ chính của từng trang (canonical)

Mỗi trang nên tự khai báo địa chỉ chính của nó, viết đầy đủ từ https://. Như vậy dù người ta vào bằng nhiều đường (có hay không có dấu /, có tham số theo dõi), Google vẫn biết gộp về một. Trong Next.js, đặt metadataBase ở layout gốc, rồi khai báo generateMetadata cho mỗi trang.

// app/[lang]/blog/[slug]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { lang, slug } = await params;          // Next 15+: params is a Promise
  const post = posts.find((p) => p.slug === slug);
  if (!isLocale(lang) || !post) return {};
  const path = `/blog/${slug}`;
  return {
    title: post.title[lang],
    description: post.description[lang],
    alternates: {
      canonical: url(lang, path),               // self-referencing canonical
      languages: languageAlternates(path),      // vi + en + x-default
    },
    openGraph: { type: "article", url: url(lang, path), locale: lang === "vi" ? "vi_VN" : "en_US",
                 publishedTime: post.published, modifiedTime: post.modified },
  };
}

Mẹo nhỏ: gom việc dựng địa chỉ vào một hàm (url(), languageAlternates()) và dùng chung cho metadata, dữ liệu có cấu trúc lẫn sơ đồ trang. Chỉ cần lệch một dấu / giữa hai nơi là hai bản ngôn ngữ không còn khớp nhau.

Nói cho Google biết bản tiếng Việt và bản tiếng Anh là một cặp (hreflang)

Thẻ này (gọi là hreflang) nói với Google: "trang này có bản tương đương bằng ngôn ngữ khác ở đây". Theo tài liệu của Google có bốn quy tắc. Phải chỉ hai chiều: A trỏ tới B mà B không trỏ lại A thì cả hai bị bỏ qua. Mỗi bản phải có cả chính nó trong danh sách. Địa chỉ phải đầy đủ, gồm cả giao thức. Và x-default là trang dự phòng cho người không khớp ngôn ngữ nào, nên hợp nhất là một trang chọn ngôn ngữ.

Mình làm một trang chọn ngôn ngữ tĩnh ở / (chỉ là trang bình thường có hai liên kết) và trỏ x-default vào đó.

export function languageAlternates(path = "") {
  return {
    vi: url("vi", path),
    en: url("en", path),
    "x-default": `${SITE_URL}/`, // the language-neutral chooser page
  };
}
Một khối gốc rẽ thành hai cổng mở rõ ràng, một xanh một hổ phách, nối nhau bằng đường chấm, và một đầu dò tròn quét cả hai
Mỗi ngôn ngữ có URL riêng, trỏ qua lại với nhau để Google thấy cả hai.

Bản đồ trang cho robot (sitemap)

Sitemap là danh sách mọi địa chỉ bạn muốn Google biết. Với site vài chục tới vài nghìn trang, mình dùng một sitemap tổng (index) trỏ tới các sitemap con; mỗi file con chứa tối đa 50.000 địa chỉ. Mình viết bằng route handler để tự kiểm soát các dòng khai báo ngôn ngữ.

// app/sitemap.xml/route.ts  (the index)
export const dynamic = "force-static";
export function GET() {
  const lastmod = new Date().toISOString().slice(0, 10);
  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <sitemap><loc>${SITE_URL}/sitemap-blog.xml</loc><lastmod>${lastmod}</lastmod></sitemap>
</sitemapindex>`;
  return new Response(xml, { headers: { "Content-Type": "application/xml; charset=utf-8" } });
}

Trong sitemap con, mỗi địa chỉ phải kèm đủ bộ liên kết ngôn ngữ của nó (vi, en, x-default), và thẻ <urlset> phải khai báo xmlns:xhtml="http://www.w3.org/1999/xhtml". Nhớ thêm dòng Sitemap: vào robots.txt, rồi gửi sitemap.xml trong Search Console. Còn robots.txt chỉ nên dùng để chỉ đường cho robot. Đừng dùng nó để giấu trang khỏi Google: địa chỉ bị chặn vẫn có thể hiện trong kết quả mà không có nội dung. Muốn giấu thật thì dùng thẻ noindex hoặc yêu cầu đăng nhập.

Cây trang web phân cấp isometric, một đầu quét tròn bên trái quét qua cây và vài ô được đánh dấu hổ phách
Sitemap index dẫn bot tới từng sitemap con và từng trang.

Giới thiệu bản thân cho Google bằng dữ liệu có cấu trúc (JSON-LD)

Đây là một đoạn dữ liệu nhỏ ghi rõ "trang này là bài viết, tác giả là ai, ngày đăng nào". Next.js khuyên đặt nó trong thẻ <script type="application/ld+json">, và thay ký tự < bằng < để nếu dữ liệu có chứa </script> thì cũng không phá được trang.

export function JsonLd({ data }: { data: Record<string, unknown> }) {
  return (
    <script type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, "\\u003c") }} />
  );
}

Mình dùng ba loại. Person ở trang chủ (tên, địa chỉ, các hồ sơ chính thức của bạn). BlogPosting ở mỗi bài (tiêu đề, ngày đăng, ngày sửa, ảnh, tác giả, ngôn ngữ). BreadcrumbList cho đường dẫn kiểu "Trang chủ > Blog > Bài này". Mọi địa chỉ trong đó phải có thật: lúc đầu mình để một mục /blog chưa tồn tại và phải bỏ đi.

Xác minh Search Console và favicon

Có hai cách xác minh bạn sở hữu site. Thẻ meta ở trang chủ hợp với kiểu thuộc tính theo địa chỉ. Bản ghi DNS là cách duy nhất cho thuộc tính "Domain", bao phủ mọi subdomain lẫn http và https. Xác minh xong đừng xoá thẻ hay bản ghi, kẻo mất quyền.

Để Google hiện được favicon (biểu tượng nhỏ cạnh kết quả), ảnh phải vuông tỉ lệ 1:1, nên lớn hơn 48x48 px, địa chỉ ổn định, và không bị robots.txt chặn. Google nói việc hiển thị không được đảm bảo và có thể mất vài ngày tới vài tuần.

Lỗi kinh điển: trang gốc tự chuyển ngôn ngữ

Rất nhiều site song ngữ làm thế này: ai vào / thì đọc ngôn ngữ trình duyệt rồi chuyển tới /vi hoặc /en. Nghe thân thiện, nhưng Googlebot không gửi thông tin ngôn ngữ và đến từ một địa chỉ có vẻ ở Mỹ, nên lần nào nó cũng bị đưa về cùng một ngôn ngữ. Nó không bao giờ tới bản kia. Thêm nữa, / trở thành một lần chuyển hướng tạm (mã 307), không phải trang bạn dùng làm x-default được.

Mình tái hiện bằng cách thêm một middleware kiểu đó vào dự án thử. Với ngôn ngữ vi-VN, curl nhận 307 về /vi. Với en-US, về /en. Không có thông tin ngôn ngữ cũng về /en, kể cả khi giả User-Agent của Googlebot. Gỡ middleware đi và để / là trang chọn ngôn ngữ tĩnh thì mọi yêu cầu đều nhận 200.

Cách an toàn: / luôn là trang cố định, mỗi ngôn ngữ có địa chỉ riêng mở được không cần cookie. Muốn gợi ý ngôn ngữ cho khách, hiện một dòng "Chuyển sang tiếng Việt?" bằng JavaScript, đừng ép chuyển ở phía máy chủ.

Mình đã kiểm gì

Hôm 21/10/2026 mình build lại dự án thử rồi đọc HTML thật. Trang /vi/blog/hello-seo có canonical đúng, đủ ba thẻ ngôn ngữ (vi, en, x-default), og:locale là vi_VN, và <html lang="vi"> lấy từ địa chỉ chứ không từ yêu cầu. Các khối dữ liệu có cấu trúc đọc ra được bằng JSON.parse, hai file sitemap là XML hợp lệ. Mình chưa chạy được Rich Results Test hay Search Console vì cần địa chỉ công khai và tài khoản, và cũng không kiểm được việc Google có hiện favicon hay không.

Làm gì tiếp theo

Việc đáng làm đầu tiên là gỡ bất cứ chỗ nào đoán ngôn ngữ ở trang gốc. Sau đó mở HTML của một bài bất kỳ bằng "Xem nguồn trang" và đếm: có canonical tuyệt đối, có đủ ba thẻ ngôn ngữ, ngôn ngữ trong thẻ <html> đúng không. Làm được đủ chừng đó là đã hơn phần lớn site hai ngôn ngữ mình từng gặp. Khi phần chỉ mục ổn, bước tiếp theo là tốc độ, mình viết ở Từ 95 lên 99 điểm tốc độ mobile: mình đã làm gì với một site Next.js.

Technical SEONext.jsSchemaseonextjshreflangsitemapjson-ldcanonical

Nguồn tham khảo

  1. Next.js docs: generateMetadata
  2. Next.js docs: JSON-LD guide
  3. Google Search Central: Localized versions (hreflang)
  4. Google Search Central: Locale-adaptive pages
  5. Google Search Central: Consolidate duplicate URLs (canonical)
  6. Google Search Central: Large sitemaps and sitemap index
  7. Google Search Central: Favicon in Search
  8. Google Search Central: Article structured data
  9. Search Console Help: Verify site ownership