---
title: "Making Sure Google Sees Both Languages of Your Next.js Site"
url: https://phatdev.net/en/blog/technical-seo-nextjs
lang: en
updated: 2026-10-21
alternate:
  vi: https://phatdev.net/vi/blog/technical-seo-nextjs
markdown: https://phatdev.net/en/blog/technical-seo-nextjs.md
---

# 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.

Categories: SEO | Published: 2026-10-19 | Level: Intermediate | 8 min read

Tags: seo, nextjs, hreflang, sitemap, json-ld, canonical

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.

```tsx
// 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.

```ts
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](https://phatdev.net/uploads/blog/technical-seo-nextjs/language-fork-1920.webp)

## 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.

```ts
// 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](https://phatdev.net/uploads/blog/technical-seo-nextjs/crawl-map-1920.webp)

## 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.

```tsx
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](https://phatdev.net/en/blog/core-web-vitals-99-case-study).

## References

- [Next.js docs: generateMetadata](https://nextjs.org/docs/app/api-reference/functions/generate-metadata)
- [Next.js docs: JSON-LD guide](https://nextjs.org/docs/app/guides/json-ld)
- [Google Search Central: Localized versions (hreflang)](https://developers.google.com/search/docs/specialty/international/localized-versions)
- [Google Search Central: Locale-adaptive pages](https://developers.google.com/search/docs/specialty/international/locale-adaptive-pages)
- [Google Search Central: Consolidate duplicate URLs (canonical)](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls)
- [Google Search Central: Large sitemaps and sitemap index](https://developers.google.com/search/docs/crawling-indexing/sitemaps/large-sitemaps)
- [Google Search Central: Favicon in Search](https://developers.google.com/search/docs/appearance/favicon-in-search)
- [Google Search Central: Article structured data](https://developers.google.com/search/docs/appearance/structured-data/article)
- [Search Console Help: Verify site ownership](https://support.google.com/webmasters/answer/9008080)

## Related posts

- [From 95 to 99 on Mobile Speed: What I Did to a Next.js Site](https://phatdev.net/en/blog/core-web-vitals-99-case-study.md)
- [Basic SEO for Online Shops: What Owners Can Do and What Needs a Developer](https://phatdev.net/en/blog/seo-basics-online-shop.md)
- [Making Your Website Readable for AI in 2026: What Is an Official Standard and What Is Only a Proposal](https://phatdev.net/en/blog/website-ready-for-ai-agents.md)
