Making Sure Google Sees Both Languages of Your Next.js Site

A two-language site can stay invisible to Google over a few small missing lines. How to declare the main address, the language pair, the sitemap and structured data, plus the root-redirect trap.

Phát LâmPublished Updated 8 min readIntermediate
Making Sure Google Sees Both Languages of Your Next.js Site
On this page
  1. How Google sees your site
  2. The main address of each page (canonical)
  3. Telling Google the Vietnamese and English pages are a pair (hreflang)
  4. A map of pages for the robot (sitemap)
  5. Introducing yourself to Google with structured data (JSON-LD)
  6. Search Console verification and the favicon
  7. The classic mistake: the root page redirecting by language
  8. What I checked
  9. What to do next

You finish a two-language site: good looking, fast. A few weeks later you search your own name on Google and see only the English version, or nothing at all. The cause is usually not the content. It's a handful of small lines in the top part of the page that visitors never see: a missing tag saying "this is the main address", two language versions that don't point at each other, or a home page that pushes everyone to one language.

This post is the list of things I did for a bilingual vi/en site built with Next.js (App Router). The examples use Next.js 16.4 in a scratch project, but these metadata features work the same way from Next 14. The main difference is that in newer versions params is a Promise, so you await it.

How Google sees your site

Google's robot (Googlebot) downloads the HTML, reads the <head>, follows links, then decides which pages to keep and which one is the "main" version. It has no account, no cookies, and it doesn't send browser language information like a real visitor. So everything important has to be in the HTML the server returns to an anonymous stranger.

The main address of each page (canonical)

Every page should state its own main address, written in full starting with https://. Then however people arrive (with or without a trailing /, with tracking parameters), Google knows to merge them into one. In Next.js, set metadataBase in the root layout, then declare generateMetadata on each page.

// 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 },
  };
}

A small tip: gather address-building into one function (url(), languageAlternates()) and share it between metadata, structured data and the sitemap. A single stray / between two places is enough to make the two language versions stop matching.

Telling Google the Vietnamese and English pages are a pair (hreflang)

This tag (called hreflang) tells Google: "this page has an equivalent in another language, here". Google's docs give four rules. The links must go both ways: if A points to B but B doesn't point back, both are ignored. Each version must list itself too. Addresses must be complete, protocol included. And x-default is the fallback for visitors matching no language, which is best served by a language chooser page.

I built a static language chooser at / (an ordinary page with two links) and pointed x-default at it.

export function languageAlternates(path = "") {
  return {
    vi: url("vi", path),
    en: url("en", path),
    "x-default": `${SITE_URL}/`, // the language-neutral chooser page
  };
}
A root block splitting into two clearly open gates, one teal and one amber, linked by a dotted line, with a round probe scanning both
Each language has its own URL, linked to each other so Google sees both.

A map of pages for the robot (sitemap)

A sitemap is a list of every address you want Google to know about. For a site with a few dozen to a few thousand pages, I use a master sitemap (an index) pointing to child sitemaps; each child file holds up to 50,000 addresses. I wrote it as a route handler so I control the language declaration lines myself.

// 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" } });
}

In the child sitemap, each address must carry its full set of language links (vi, en, x-default), and the <urlset> tag must declare xmlns:xhtml="http://www.w3.org/1999/xhtml". Add a Sitemap: line to robots.txt and submit sitemap.xml in Search Console. Use robots.txt only to point robots in the right direction. Don't use it to hide a page from Google: a blocked address can still show up in results with no content. To hide a page for real, use a noindex tag or require a login.

A hierarchical isometric site tree with a round scanner on the left sweeping across it and a few tiles marked in amber
The sitemap index leads the crawler to each child sitemap and each page.

Introducing yourself to Google with structured data (JSON-LD)

This is a small block of data that says "this page is an article, written by this person, published on this date". Next.js recommends putting it in a <script type="application/ld+json"> tag, and replacing the < character with < so that data containing </script> can't break the page.

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

I use three types. Person on the home page (name, address, your official profiles). BlogPosting on each post (title, publish date, modified date, image, author, language). BreadcrumbList for the "Home > Blog > This post" trail. Every address inside must exist: at first I included a /blog item that didn't exist yet and had to drop it.

Search Console verification and the favicon

There are two ways to prove you own the site. A meta tag on the home page suits address-prefix properties. A DNS record is the only way for a "Domain" property, which covers every subdomain and both http and https. After verifying, don't delete the tag or record, or you lose access.

For Google to show a favicon (the small icon beside results), the image must be square at 1:1, preferably larger than 48x48 px, with a stable address that isn't blocked by robots.txt. Google says display isn't guaranteed and can take a few days to a few weeks.

The classic mistake: the root page redirecting by language

Many bilingual sites do this: anyone arriving at / has their browser language read and is sent to /vi or /en. It sounds friendly, but Googlebot doesn't send language information and appears to come from a US address, so it's sent to the same language every time. It never reaches the other version. On top of that, / becomes a temporary redirect (status 307), which you can't use as the x-default page.

I reproduced it by adding that kind of middleware to the scratch project. With language vi-VN, curl got a 307 to /vi. With en-US, to /en. With no language information it also went to /en, even with a Googlebot User-Agent. Removing the middleware and making / a static chooser page made every request return 200.

The safe approach: / is always a fixed page, and each language has its own address that opens without cookies. If you want to suggest a language to visitors, show a "Switch to Vietnamese?" line with JavaScript instead of forcing a redirect on the server.

What I checked

On 21 Oct 2026 I rebuilt the scratch project and read the real HTML. The page /vi/blog/hello-seo has the correct canonical, all three language tags (vi, en, x-default), og:locale set to vi_VN, and <html lang="vi"> taken from the address rather than the request. The structured-data blocks parse with JSON.parse and both sitemap files are valid XML. I couldn't run the Rich Results Test or Search Console because they need a public address and an account, and I can't verify whether Google shows the favicon.

What to do next

The first thing worth doing is removing any spot that guesses language at the root page. Then open the HTML of any post with "View page source" and count: is there an absolute canonical, all three language tags, and the right language in the <html> tag? Getting just that far puts you ahead of most two-language sites I've seen. Once indexing is in order, speed is the next step, which I wrote up in From 95 to 99 on Mobile Speed: What I Did to a Next.js Site.

Technical SEONext.jsSchemaseonextjshreflangsitemapjson-ldcanonical

References

  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