# General Translation Knowledge Base: How do I set up SEO for a multilingual Next.js app?
URL: https://generaltranslation.com/en-US/kb/multilingual-nextjs-seo.mdx
Docs index: https://generaltranslation.com/llms.txt
Description: Give each language its own prefixed URL, such as /en/about and /es/about, then publish html lang, a self-referencing canonical, hreflang alternates with x-default, a translated title and description, and a sitemap entry for each published language. Mark unpublished versions noindex. General Translation's gt-next routes each language to its own URL and translates titles with getGT.
Category: Multilingual SEO
Last updated: 2026-10-22
Authors: General Translation Team
Published: 2026-02-17

Multilingual SEO in Next.js means giving each language version of a page its own URL and telling search engines how those URLs relate. A page that picks its language from a cookie or the `Accept-Language` header has one URL, so search engines usually index it in one language only. Every language version needs the six requirements in this table, and routing, metadata, and the sitemap must agree on each language's URL.

| Requirement | What it does | Where it goes in Next.js |
| --- | --- | --- |
| A URL per language | Each version is a separate page to crawl and index | A `[locale]` route segment and locale routing middleware |
| `<html lang>` | Identifies the page language for browsers and assistive technology | The root layout, `app/[locale]/layout.tsx` |
| Canonical URL | This URL is the preferred version for its own language, not a duplicate of another language | `alternates.canonical` in `generateMetadata` |
| `hreflang` alternates | Every published language version of the page, including this one, plus an `x-default` fallback | `alternates.languages` in `generateMetadata` |
| Translated title and description | The text search results usually show in that language | `title` and `description` in `generateMetadata` |
| Sitemap entry | Every published language URL of every page | `app/sitemap.ts` |

General Translation's `gt-next` routes each language to a URL such as `/es/about` and translates titles and descriptions with [`getGT`](/docs/react/nextjs/reference/functions/get-gt). The canonical URL, `hreflang` alternates, and sitemap come from the Next.js [Metadata API](https://nextjs.org/docs/app/api-reference/functions/generate-metadata#alternates).

## Video

The video is narrated in English, with English captions.

[How do I set up SEO for a multilingual Next.js app?](https://caxecubb9c64cezv.public.blob.vercel-storage.com/films/kb-multilingual-nextjs-seo.mp4) (50 sec)

General Translation lets you give every language its own URL and tell search engines about them. Move your pages into `app/[locale]` with `gt-next`'s middleware, set the html `lang` attribute with `useLocale`, add canonical and hreflang links with a helper, translate the title with `getGT`, and list every page in every language in a sitemap.

## Why does Google index only one language of my Next.js site?

Google indexes one language when every language shares one URL. Googlebot [sends requests without an `Accept-Language` header](https://developers.google.com/search/docs/specialty/international/locale-adaptive-pages) and [does not keep cookies between page loads](https://developers.google.com/search/docs/crawling-indexing/javascript/fix-search-javascript), so a page that picks its language from either one shows Google its default language every time. The other languages have no URL for Google to crawl.

The fix is a separate URL for each language, listed in `hreflang` alternates and the sitemap. Google [recommends separate URLs for each language version](https://developers.google.com/search/docs/specialty/international/managing-multi-regional-sites) over cookies or browser settings. A General Translation `gt-next` app without middleware has the same problem: its locale selector stores the choice in a cookie and keeps URLs unprefixed until you add [`createNextMiddleware`](/docs/react/nextjs/reference/functions/create-next-middleware).

Fully translated English and French pages are not duplicates merely because they describe the same product. Google considers localized versions duplicates only when the main content remains untranslated. (See Google's [localized-page guidance](https://developers.google.com/search/docs/specialty/international/localized-versions)).

## Should language versions use subdirectories, subdomains, or separate domains?

Use subdirectories, such as `example.com/es/about`, unless each market needs its own server location or its own site. Google's guide to [managing multi-regional and multilingual sites](https://developers.google.com/search/docs/specialty/international/managing-multi-regional-sites) lists subdirectories as easy to set up and low maintenance, because every language stays on one host, and does not recommend URL parameters.

| Structure | Example | Google's assessment | In a Next.js app |
| --- | --- | --- | --- |
| Subdirectories | `example.com/es/about` | Easy to set up, low maintenance (same host), one server location | General Translation's `gt-next` middleware serves this structure by default |
| Subdomains | `es.example.com/about` | Easy to set up, allows different server locations | Host-based routing you write; [`createNextMiddleware`](/docs/react/nextjs/reference/functions/create-next-middleware) routes by path |
| Country domains | `example.es/about` | Clear geotargeting, but expensive, more infrastructure, and one country per domain | A deployment or host-based routing for each domain |
| URL parameters | `example.com/about?lang=es` | Not recommended | Avoid |
| Cookie or header only | `example.com/about` | Separate URLs recommended instead | Indexed in one language only |

Use subdomains or country domains when markets need separate hosting or country targeting. For a single Next.js deployment, subdirectories need the least code: General Translation's `gt-next` middleware serves `/es/about` by default, while subdomains and country domains need host-based routing you write. (See the [URL structure comparison](/kb/multilingual-url-structure) for the tradeoffs.)

## How do I give each language its own URL in Next.js?

Add locale routing middleware and an `app/[locale]` route segment. With General Translation's `gt-next`, create `proxy.ts` in the project root (`middleware.ts` on Next.js 15 and earlier):

```ts title="proxy.ts"
import { createNextMiddleware } from 'gt-next/middleware';

export default createNextMiddleware({ prefixDefaultLocale: true });

export const config = {
  matcher: ['/((?!api|static|.*\\..*|_next).*)'],
};
```

Then move your pages under `app/[locale]`:

```text
proxy.ts
app/
├── sitemap.ts
└── [locale]/
    ├── layout.tsx
    ├── page.tsx
    └── about/
        └── page.tsx
```

`prefixDefaultLocale: true` puts every locale, including English, under a prefix, so each content URL always serves one language. This article uses that configuration throughout. An unprefixed path such as `/about` becomes an entry point: the middleware answers with a temporary `307` redirect to the visitor's saved locale or best `Accept-Language` match, or to `/en/about` when there's neither. A localized URL never moves; `/en/about` returns English even to a browser that prefers French. Without the option, the default locale is unprefixed and `/about` can redirect by cookie or browser header. (See [locale routing and SEO](/kb/locale-routing-seo) for why that matters.)

Two more settings change routing:

- [`pathConfig`](/docs/react/nextjs/reference/functions/create-next-middleware#path-config) translates paths, for example `/fr/a-propos` for `/about`.
- [`ignoreBrowserLocales: true`](/docs/react/nextjs/config#ignore-browser-locales) in `withGTConfig` stops the middleware from picking a locale from the `Accept-Language` header. Google [advises against redirecting](https://developers.google.com/search/docs/specialty/international/managing-multi-regional-sites) visitors based on a guess about their language.

If you prerender pages with `generateStaticParams`, also add `getLocale.ts` at the project root, because middleware does not run during static generation. The [App Router middleware guide](/docs/react/nextjs/app-router-middleware#root-locale) has the file and the full routing steps.

## How do I set the html lang attribute for each locale?

Set `lang` from the `[locale]` route parameter in the root layout, `app/[locale]/layout.tsx`. With the [`isLocaleSupported`](/docs/react/nextjs/reference/functions/is-locale-supported) check, a locale you haven't configured returns a 404 instead of falling back to the default locale at that URL:

```tsx title="app/[locale]/layout.tsx"
import { GTProvider, isLocaleSupported } from 'gt-next';
import { notFound } from 'next/navigation';

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;

  if (!isLocaleSupported(locale)) {
    notFound();
  }

  return (
    <html lang={locale}>
      <body>
        <GTProvider>{children}</GTProvider>
      </body>
    </html>
  );
}
```

The `lang` attribute identifies the page language for assistive technology and language-aware browser behavior. Google determines language from the [visible content](https://developers.google.com/search/docs/specialty/international/managing-multi-regional-sites#language), not the `lang` attribute, so keep it accurate for accessibility rather than as a ranking switch.

The root layout belongs at `app/[locale]/layout.tsx` when pages are prerendered, because a layout above the `[locale]` segment can't read the locale during static generation. If a URL segment is an alias, such as `/cn` for Chinese, convert it with [`resolveCanonicalLocale`](/docs/react/nextjs/reference/functions/resolve-canonical-locale) so `lang` is `zh`. [Managing locale alias SEO](/docs/react/nextjs/locale-alias-seo) covers aliases in metadata and the sitemap.

## How do I add hreflang alternate links and canonical URLs in the Next.js App Router?

In the Next.js App Router, add `hreflang` alternate links by returning `alternates.languages` from each page's `generateMetadata`, and the canonical URL by returning `alternates.canonical`. Next.js renders each `alternates.languages` entry as a `<link rel="alternate" hreflang>` tag.

Google requires [each language version to list itself and every other version](https://developers.google.com/search/docs/specialty/international/localized-versions), and ignores `hreflang` links that two pages don't both declare, so every version of a page must return the same list. The `x-default` entry is the URL for visitors whose language matches none of the versions. Each version's canonical points to its own URL; avoid pointing translated pages at English. A canonical is a signal, not a guarantee. (See [hreflang and canonical tags](/kb/what-is-hreflang#should-hreflang-pages-use-self-referencing-canonicals)).

A translation can lag behind its page, so drive the canonical, the alternates, and the sitemap from one record of which locales each page is published in. This helper builds the canonical and the alternates from that record, and marks a version that isn't published yet `noindex`:

```ts title="lib/i18n-metadata.ts"
import type { Metadata } from 'next';
import { getLocale } from 'gt-next/server';

export const BASE_URL = 'https://example.com';

// The locales each page is published in. Add a locale once its translation is reviewed.
export const PUBLISHED: Record<string, string[]> = {
  '/': ['en', 'fr', 'es'],
  '/about': ['en', 'fr', 'es'],
  '/pricing': ['en', 'fr'], // Spanish isn't translated yet
};

export const localeUrl = (locale: string, path: string) =>
  `${BASE_URL}/${locale}${path === '/' ? '' : path}`;

export async function getI18NMetadata(path: string): Promise<Metadata> {
  const locale = await getLocale();
  const published = PUBLISHED[path] ?? [];

  // Keep an unpublished version out of the index.
  if (!published.includes(locale)) {
    return {
      robots: { index: false },
      alternates: null,
    };
  }

  const languages: Record<string, string> = {};
  for (const loc of published) languages[loc] = localeUrl(loc, path);
  languages['x-default'] = localeUrl(published.includes('en') ? 'en' : published[0], path);

  return { alternates: { canonical: localeUrl(locale, path), languages } };
}
```

Then call it from each page:

```tsx title="app/[locale]/pricing/page.tsx"
import { getI18NMetadata } from '@/lib/i18n-metadata';

export async function generateMetadata() {
  return getI18NMetadata('/pricing');
}
```

Built with Next.js 16.4.2 and `gt-next` 11.4.14, `/fr/about`, which is published in all three languages, rendered this `<head>`:

```html title="/fr/about"
<html lang="fr">
<link rel="canonical" href="https://example.com/fr/about"/>
<link rel="alternate" hrefLang="en" href="https://example.com/en/about"/>
<link rel="alternate" hrefLang="fr" href="https://example.com/fr/about"/>
<link rel="alternate" hrefLang="es" href="https://example.com/es/about"/>
<link rel="alternate" hrefLang="x-default" href="https://example.com/en/about"/>
```

`/fr/pricing` lists no Spanish alternate, because Spanish pricing isn't published:

```html title="/fr/pricing"
<html lang="fr">
<link rel="canonical" href="https://example.com/fr/pricing"/>
<link rel="alternate" hrefLang="en" href="https://example.com/en/pricing"/>
<link rel="alternate" hrefLang="fr" href="https://example.com/fr/pricing"/>
<link rel="alternate" hrefLang="x-default" href="https://example.com/en/pricing"/>
```

The route still answers at `/es/pricing`, but its `<head>` carries only `<meta name="robots" content="noindex"/>` and no canonical or alternates, so crawlers can discover the `noindex` instruction. Setting `alternates: null` clears any canonical or alternates inherited from a parent layout. Keep the route crawlable; `noindex` is not access control. `x-default` points at English when published, otherwise the first published locale. The helper assumes the same path in every locale; with translated paths from `pathConfig`, localized slugs, or route aliases, build each locale's URL from that route map instead, so `fr` points to `/fr/a-propos`.

Also provide a [language switcher](/kb/language-switcher) with ordinary links to equivalent pages. Hreflang does not replace a visible navigation choice.

## How do I translate page titles and meta descriptions for each locale in Next.js?

Translate the strings that `generateMetadata` returns. Search results usually show a page's title and description, so a Spanish page with an English title shows English text in Spanish search results. Search engines can also rewrite displayed titles and snippets, so supplied metadata is not a promise of exact presentation. General Translation's [`getGT`](/docs/react/nextjs/reference/functions/get-gt) from `gt-next/server` returns a translation function for the current request's locale, and it works inside `generateMetadata`. Return the translated title and description together with the helper's alternates:

```tsx title="app/[locale]/about/page.tsx"
import type { Metadata } from 'next';
import { getGT } from 'gt-next/server';
import { getI18NMetadata } from '@/lib/i18n-metadata';

export async function generateMetadata(): Promise<Metadata> {
  const gt = await getGT();
  const i18nMeta = await getI18NMetadata('/about');

  return {
    title: gt('About us'),
    description: gt('Meet the team that builds our product.'),
    ...i18nMeta,
  };
}
```

Pass string literals, as above, so the build step can find them. Production translations for [`getGT`](/docs/react/nextjs/reference/functions/get-gt) are prepared before deployment, so run [`npx gt translate`](/docs/cli/reference/commands/translate) before `next build`:

```json title="package.json"
{
  "scripts": {
    "build": "npx gt translate && next build"
  }
}
```

When the English title changes, the next build translates the new text into every locale, with no message files to update. Review titles and descriptions in the [Dashboard editor](/docs/platform/dashboard/guides/reviewing-translations) before adding a locale to `PUBLISHED`. Shared [Glossary terms](/docs/platform/dashboard/guides/defining-context-for-translations) help keep product names consistent between the page and its search metadata. Measure actual impressions, clicks, and conversions after publication rather than assuming a fixed click-through improvement. (See [How do I translate SEO titles and meta descriptions?](/kb/translate-seo-metadata) for keywords per market, length across languages, and Open Graph tags.)

## How do I add every language to a Next.js sitemap?

List one entry per page per published locale in `app/sitemap.ts`. Read the same `PUBLISHED` record so the sitemap and the `hreflang` links never disagree:

```ts title="app/sitemap.ts"
import type { MetadataRoute } from 'next';
import { PUBLISHED, localeUrl } from '@/lib/i18n-metadata';

export default function sitemap(): MetadataRoute.Sitemap {
  return Object.entries(PUBLISHED).flatMap(([path, locales]) =>
    locales.map((locale) => ({ url: localeUrl(locale, path) })),
  );
}
```

The generated `/sitemap.xml` listed eight URLs: `/en`, `/fr`, `/es`, the three `/about` versions, `/en/pricing`, and `/fr/pricing`. `/es/pricing` stays out until it's published. If you add `lastModified`, use the date of the page's actual substantive update, not the time the sitemap was generated. (See Google's [sitemap guidance](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap)).

Because the page metadata already declares `hreflang` in HTML, this sitemap only lists the URLs. You can declare `hreflang` in the sitemap instead: give each entry an `alternates.languages` map, which Next.js 14.2 and later writes as `xhtml:link` alternates inside each `<url>`, and Google treats [`hreflang` in a sitemap](https://developers.google.com/search/docs/specialty/international/localized-versions) the same as `hreflang` in HTML. Google says maintaining both methods provides no additional Search benefit, so choose one. The Next.js [sitemap reference](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap#generate-a-localized-sitemap) documents the `alternates` field. (See [How do I create a sitemap for a multilingual website?](/kb/multilingual-sitemap) for size limits, splitting with a sitemap index, and checks before you submit.)

## Which Next.js i18n library handles locale routes and hreflang for SEO?

For SEO, a Next.js i18n library has to give every language its own URL. General Translation's `gt-next` and next-intl both do this with App Router middleware; i18next and react-intl leave routing to you. next-intl's middleware also sends `hreflang` alternates in an HTTP header, and `gt-next` translates titles and descriptions in the build without message files. None of these libraries writes the canonical URL or the sitemap for you.

| Approach | Language URLs | `hreflang` | Translated titles and descriptions | Canonical URL and sitemap |
| --- | --- | --- | --- | --- |
| next-intl | Middleware with locale prefixes (`/en/about` by default), localized pathnames, or a domain per locale | Middleware [sends alternates, including `x-default`, in a `Link` HTTP header](https://next-intl.dev/docs/routing/configuration#alternate-links) by default | Messages your team or a translation service writes in each language's JSON catalog | You write them |
| i18next or react-intl | No Next.js routing; you write the middleware | You write them | Messages in each language's catalog | You write them |
| Google Translate in the visitor's browser | One URL per page | None | Translated in the browser only; search results show the source language | One URL, so nothing to list |
| A raw AI API (OpenAI API, Claude API) | Routes you build | You write them | A script you run again after every copy change | You write them |
| General Translation (`gt-next` and the `gt` CLI) | [`createNextMiddleware`](/docs/react/nextjs/reference/functions/create-next-middleware) serves `/en/about`, `/es/about`, and translated paths such as `/fr/a-propos` | You return `alternates.languages` from `generateMetadata`, built from a record of published locales | [`getGT`](/docs/react/nextjs/reference/functions/get-gt) strings, translated by [`npx gt translate`](/docs/cli/reference/commands/translate) before `next build` | You write them in `generateMetadata` and `app/sitemap.ts` from the same record |

Use next-intl when your team already maintains message catalogs and wants `hreflang` sent from middleware. Use i18next or react-intl when you have translators, copy that rarely changes, and time to write the routing. Use browser translation only for pages that need no search traffic in other languages, such as an app behind a login, which search engines can't crawl. Use General Translation's `gt-next` when the app ships new copy often and every language version needs its own indexed page and translated title: copy stays inline in your components, and the build translates pages and titles together. Profound's platform and docs ship in 47+ locales with General Translation. (See [production deployments](https://generaltranslation.com/enterprise).)

## Related questions

- [Language redirects and SEO: URL, cookie, or Accept-Language?](https://generaltranslation.com/en-US/kb/locale-routing-seo.mdx): Let the URL decide the language of every page you want indexed, because Googlebot sends no Accept-Language header and crawls mostly from US addresses. Use a saved cookie choice, then Accept-Language, only to pick where a neutral root such as / redirects, with a temporary 302 or 307.
- [What is hreflang, and how do I set it up?](https://generaltranslation.com/en-US/kb/what-is-hreflang.mdx): Hreflang tells Google which URLs are language or regional versions of the same page, so it can show the right one in search results. Add it with HTML link tags, HTTP Link headers, or a sitemap, make every link reciprocal, and use valid codes such as en-GB, not en-UK.
- [Subdirectory vs. subdomain vs. ccTLD for multilingual SEO](https://generaltranslation.com/en-US/kb/multilingual-url-structure.mdx): Use subdirectories such as example.com/de/ when one product shares a site and release process. Only a ccTLD signals a country, subdomains allow separate hosting, Google discourages ?lang= parameters, and it treats vanity domains such as .io and .ai as generic.
- [Language switcher best practices: UX, SEO, and accessibility](https://generaltranslation.com/en-US/kb/language-switcher.mdx): Name each language in its own language, such as Deutsch or 日本語, and don't use flags, which represent countries. Link each option to the same page in that language with a crawlable link, remember the choice in a cookie, and never auto-redirect a localized URL.
- [Localization testing checklist for multilingual apps](https://generaltranslation.com/en-US/kb/multilingual-app-launch-checklist.mdx): Test nine things in every supported locale on a production build: whole journeys including error states and emails, language switching, plurals, currency and time zones, text expansion, right-to-left layout, accessibility, translation delivery, and crawlable language URLs. Give each check an explicit pass condition.

## Previous and next questions

- Previous question: [How do I translate conditional text in JSX?](https://generaltranslation.com/en-US/kb/translate-conditional-jsx.mdx)
- Next question: [What is the best localization software for developers?](https://generaltranslation.com/en-US/kb/best-localization-software.mdx)

## Sitemap

See the [Knowledge Base sitemap](https://generaltranslation.com/en-US/kb/sitemap.md) for every article.
