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/serviciosor/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:
- next-intl already does it in the proxy: by default it sends an HTTP
Linkheader with onehreflangentry per language plus anx-default. You can turn it off withalternateLinks: false. - 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,/blogshows 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
- next-intl: Routing configuration: localePrefix, localeDetection, pathnames and alternateLinks.
- next-intl: Proxy / middleware.
- next-intl: Error handling and missing messages.
- Next.js: Upgrading to version 16: middleware to proxy.