Skip to content
Full journal

Published on October 14, 2026

A site in four languages with Next.js 16 and next-intl

How we took kitusoft.com to Spanish, English, French and German: file structure, per-language URLs, hreflang, database content and the decisions that matter most for SEO.

nextjs · next-intl · i18n · seo

kitusoft.com is in Spanish, English, French and German. We built it with Next.js 16 (App Router) and next-intl. This article explains how it's organised and the decisions we made, with the official documentation at hand.

The minimal structure

src/
  i18n/
    routing.ts      <- languages, default language and routes
    request.ts      <- loads the messages for each request's language
    navigation.ts   <- language-aware Link, useRouter, usePathname
  proxy.ts          <- detects the language and redirects
  app/[locale]/...  <- every page
messages/
  es.json  en.json  fr.json  de.json

One Next.js 16 detail: the file formerly called middleware.ts is now proxy.ts (the old name is deprecated). next-intl's own documentation already uses the new name.

The configuration, in one place

export const routing = defineRouting({
  locales: ["es", "en", "fr", "de"],
  defaultLocale: "es",
  localePrefix: "always",
  localeDetection: false,
  pathnames: {
    "/servicios": { es: "/servicios", en: "/services", fr: "/services", de: "/leistungen" },
    "/contacto": { es: "/contacto", en: "/contact", fr: "/contact", de: "/kontakt" },
    // ...
  },
});

Three decisions live in those lines.

1. localePrefix: "always": each language with its own URL

next-intl offers three modes:

  • always (the default): every URL starts with the language, e.g. /es/servicios or /en/services.
  • as-needed: the default language has no prefix (/servicios) and the others do (/en/services).
  • never: the language isn't in the URL. In this mode next-intl disables alternate links.

We chose always: each version has its own stable address, and it's easy to tell search engines which page translates which.

2. localeDetection: false: don't guess the language

By default next-intl picks the language from the browser's Accept-Language header or a cookie. With localeDetection: false, only the URL prefix counts. We're an Ecuadorian studio and prefer visitors arriving at the root to see Spanish first; switching language is one click away.

3. pathnames: translated URLs

With pathnames each page can have a different URL per language while the code lives in a single folder. For example, /es/servicios, /en/services and /de/leistungen are all served by app/[locale]/servicios/page.tsx. Journal, app and legal URLs are the same in every language on purpose, because other sites already link to them.

If you change URLs that already existed, add permanent (308) redirects from the old ones. We did when moving from /en/servicios to /en/services.

A page with translations

export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
  const { locale } = await params;
  setRequestLocale(locale);
  const t = await getTranslations("services");
  return <h1>{t("title")}</h1>;
}

If a key is missing, next-intl logs an error to the console and renders the key name (services.title) instead of the text. To keep that out of production, an automated test compares the four message files and fails if a language has missing or extra keys.

hreflang: two layers

So that search engines know /es/servicios and /en/services are the same page in two languages:

  1. next-intl already does it in the proxy: by default it sends an HTTP Link header with one hreflang entry per language plus an x-default. You can turn it off with alternateLinks: false.
  2. We also declare it in the HTML, with the Next.js Metadata API, and in the sitemap:
export async function generateMetadata({ params }) {
  const { locale } = await params;
  return {
    alternates: {
      canonical: `/${locale}/...`,
      languages: { es: "/es/servicios", en: "/en/services", fr: "/fr/services", de: "/de/leistungen", "x-default": "/es/servicios" },
    },
  };
}

On kitusoft.com a single function builds those URLs from the same pathnames configuration, so they never drift apart.

The language switcher

Our switcher is a button that opens a list of real links, one per language, to the same page in that language. Because they're links, they can be opened in a new tab and the browser understands them without tricks. A <select> would need JavaScript to navigate.

On pages whose content depends on the language, like journal entries, the switcher goes to the journal index in the other language: each translation has its own slug, and linking the same slug would be a 404.

Content that lives in the database

Interface text lives in the JSON files. Firestore content was handled like this:

  • Journal: each article is two documents, one in Spanish and one in English, linked by a translationKey. The admin panel edits them side by side. The journal only exists in those two languages; in French and German, /blog shows a notice pointing to the existing versions and isn't indexed.
  • Shop: a product is a single document, the same in every language.

What we learned

  • Decide the URLs early. Changing them later means maintaining redirects.
  • Test translation parity. A test that compares keys prevents missing text in production.
  • hreflang in the header, the HTML and the sitemap. next-intl covers the header; the rest is little code and removes ambiguity.
  • Separate interface and content. JSON files are for the interface; editorial content needs its own translation model.

Sources