
Building a Multi‑Language Shop with Next.js App Router

Introduction
Adding multiple languages to a web application often starts with a simple step: replacing hardcoded strings with translation keys and adding a language switcher.
In an e-commerce application, however, internationalization quickly becomes a broader architectural challenge.
Language affects far more than the text displayed on the page:
- It becomes part of the URL,
- it influences routing and redirects,
- it determines which translation resources are loaded,
- it affects server and client rendering,
- it changes how data such as prices and dates should be formatted.
In this article, we’ll look at how to approach these problems in a Next.js application using the App Router. Rather than focusing on the basics of translating strings, we’ll focus on the technical decisions behind the implementation: how the language is detected, how it’s propagated through the application, how translations are loaded on the server and client, and how localized routes are handled.
The implementation presented here is based on a small e-commerce shop, with a product page as its main piece of domain content. It supports English, Polish, and German, and it intentionally stays focused on the internationalization layer rather than on building a complete commerce platform.
The goal is to use this relatively simple application to explore the architecture behind multilingual Next.js applications and the trade-offs involved in choosing and implementing an i18n solution.
Implementation Remarks
Before diving into the implementation, it’s worth noting a few deliberate simplifications in this project.
The repository is intentionally designed to demonstrate the technical aspects of internationalization in Next.js, not to provide a complete, production-ready e-commerce application. As a result, some parts of the implementation are intentionally simplified.
Product data is part of the translation resources
To keep the example self-contained, product data such as names and descriptions is stored alongside the translation resources.
In a real e-commerce application, this data would typically come from a domain-specific API or commerce backend:
Product page ↓ Commerce API ↓ Product data
In that setup, the i18n layer would primarily handle UI translations, while localized product content would be part of the product data model.
All languages are stored in a single file
The example keeps translations for all supported languages in one file to make the structure easier to explore.
In a larger application, each language would typically have its own resource files:
languages/ ├── en/ │ ├── common.json │ ├── home.json │ └── product.json ├── pl/ │ ├── common.json │ ├── home.json │ └── product.json └── de/ ├── common.json ├── home.json └── product.json
This makes translation files easier to manage independently and fits better with external localization workflows.
No external translation management system
The project uses local resources and an API route to demonstrate the resource-loading boundary.
In a production application, you might instead use a CMS, a translation management platform, or another external service. The resourceLoader abstraction is intentionally designed so the underlying source can be swapped out without changing the components that consume translations.
Choosing an i18n Solution for Next.js
Before implementing internationalization, the first step is choosing the right tool.
Next.js provides the routing primitives needed to build a multilingual application, but it doesn’t prescribe a translation library. That leaves several options: building a small custom solution, using a Next.js-focused library such as next-intl, or adopting the broader i18next ecosystem through next-i18next.
For this project, I chose next-i18next.
That decision wasn’t based solely on the number of translated strings. The application needs an i18n layer that works across Server and Client Components, supports namespaces, and gives us control over how translation resources are loaded. These are important capabilities for a shop where translations may eventually move from local files to an external CMS or another backend service.
next-i18next is built on top of i18next and react-i18next, while adding the Next.js-specific integration. The version used in this project supports the App Router, including Server Components, Client Components, and proxy-based language detection**.** It also provides separate APIs such as getT() for Server Components and useT() for Client Components.
Why not build it ourselves?
A custom implementation might initially look attractive. With a small application, a simple dictionary and a React context can be enough:
const translations = { en: { ... }, pl: { ... }, de: { ... }, };
The problem is that translation lookup is only one part of the equation.
Once the application needs server-side translations, client-side hydration, namespaces, fallback languages, language detection, or external translation resources, the custom solution starts accumulating infrastructure that an established i18n library already provides.
There’s another important advantage: a mature i18n library gives us much more than a simple key → string lookup.
For example, i18next provides built-in support for interpolation:
t("welcome", { name: "John" });
It also supports pluralization without requiring us to implement language-specific rules ourselves, and react-i18next provides the <Trans> component for translations that contain React elements or more complex markup.
These features may seem like minor conveniences at first, but they quickly become important as the number and complexity of translations grow. Pluralization is particularly difficult to implement correctly because different languages can have completely different grammatical rules.
Language-Based Routing
Once we have chosen the i18n library, the next decision is where the current language should live.
For this application, the language is part of the URL:
<aside>/en /pl /de
</aside>The same applies to nested routes:
<aside>/en/product/1 /pl/product/1 /de/product/1
</aside>This is implemented using a dynamic segment in the App Router:
!image.png
The [lng] segment becomes a route parameter, so a product page receives both the language and the product identifier:
const { id, lng } = await params;
A small App Router detail: Starting with Next.js 16.3.0, you can also access route parameters via
next/root-params. For example, instead of passinglngthrough multiple component levels, a component can use:import { lng } from "next/root-params";This can reduce prop drilling when deeper components need the language. It’s especially useful for components that rely on route parameters but don’t otherwise need props from their parent.
Why put the language in the URL?
The first benefit is that each language has its own URL.
A user can navigate directly to:
/pl/product/1
and the server immediately knows which language to use to render the page.
The URL is also shareable and bookmarkable. If someone sends a Polish product page to another user, they’re sharing the Polish version explicitly rather than relying on the recipient’s browser preferences.
There is also a clear relationship between URLs:
/en/product/1 /pl/product/1 /de/product/1
They represent the same resource in different languages.
This is especially useful for SEO, where language-specific URLs can be indexed and connected using mechanisms such as hreflang.
Detecting the User's Language with Proxy
Putting the language in the URL solves the routing problem, but it raises another question: what should happen when a user visits the application without specifying a language?
For example:
GET /
There is no language in the URL, so the application needs a way to determine the most appropriate one.
In this project, that logic lives in the Next.js Proxy and follows a simple priority order:
URL ↓ Cookie ↓ Accept-Language ↓ Fallback
The key point is that these sources are not treated equally.
URL comes first
If the requested URL already contains a supported language, we treat it as an explicit user choice.
For example:
/pl/product/1
should always be rendered in Polish, even if the user's browser prefers German.
The Proxy first extracts the first segment of the pathname and checks whether it is one of the supported languages:
const { pathname } = request.nextUrl; const urlLanguage = pathname.split("/")[1]; if (supportedLanguages.includes(urlLanguage)) { // ... }
When a valid language is found, the request can continue normally.
The Proxy also updates the language cookie to remember the user's explicit choice for subsequent visits.
Cookie as persistent preference
If there is no language in the URL, the next source is the language cookie.
The cookie allows us to remember a language selected during a previous visit without requiring the user to select it again.
However, the cookie is deliberately lower in the priority chain than the URL.
This means that:
URL: /de Cookie: pl
results in German.
The URL represents the current request, while the cookie represents a previously stored preference.
Keeping this distinction prevents a stale cookie from overriding an explicit URL.
Falling back to Accept-Language
If neither the URL nor the cookie provides a supported language, we can use the browser's Accept-Language header.
A browser might send something like:
pl-PL,pl;q=0.9,en-US;q=0.8,en;q=0.7
The application extracts the language codes and looks for the first one it supports.
For this project, the supported languages are:
en pl de
so the example header would result in:
pl
It is important to treat Accept-Language as a preference, rather than as a guaranteed language selection. The browser may request a language that the application does not support.
Finally, use a fallback
There is always a case where none of the detected languages are supported.
For example:
URL: / Cookie: fr Header: ja-JP,ja;q=0.9
If the application only supports English, Polish and German, there is no match.
This is where the configured fallback language is used:
en
Translation Resources and Namespaces
Once the language is established, the next question is how the application retrieves the translations.
The project uses three namespaces:
common home product
Rather than keeping every translation in a single large dictionary, namespaces let us group resources by responsibility.
For example:
common ├── navigation ├── language switcher └── shared UI home ├── hero └── product grid product ├── product details ├── add to cart └── stock information
This becomes increasingly useful as the application grows. A single translation file works well at first, but over time it becomes harder to maintain and to understand which part of the application owns a particular key.
Namespaces also provide a useful loading boundary. A product page doesn’t necessarily need every translation used by the homepage.
Loading resources through resourceLoader
The interesting part of this implementation is how those resources are loaded.
Instead of importing the translation JSON directly into the application, next-i18next is configured with a custom resourceLoader:
resourceLoader: async (language, namespace) => { const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"; const res = await fetch( `${baseUrl}/api/fetchTranslations?lang=${language}&ns=${namespace}`, ); if (!res.ok) { throw new Error( `Failed to load translations: ${language}/${namespace}`, ); } return res.json(); },
Why introduce an API layer?
For local JSON files, calling an API might seem unnecessary—we could simply import them.
The API is included here to illustrate a more realistic setup, where translations are managed outside the application bundle.
Imagine replacing the current implementation with:
resourceLoader ↓ CMS
or:
resourceLoader ↓ translation service
The rest of the application can remain unchanged.
This separation is especially useful for e-commerce applications, where translations are often managed by content or localization teams rather than developers.
Server and Client Components
With the language and translation resources in place, we still need to answer one key question: how do Server Components and Client Components access translations?
The App Router provides two execution environments, and next-i18next offers an API for each.
On the server, translations can be accessed with getT():
const { t } = await getT("product"); const title = t("title");
On the client, translations are accessed through useT():
const { t } = useT("common"); return <button>{t("addToCart")}</button>;
This distinction matters because using translations doesn’t automatically mean a component needs to become a Client Component.
Keep translated content on the server
A product page is a good example.
There’s usually no reason to move the entire page to the client just because it contains translated text. The product name, description, labels, and other static content can be translated as the page is rendered on the server.
That way, the application can send translated HTML in the initial response instead of waiting for client-side JavaScript to initialize the translation layer.
Providing the resources from the layout
The [lng]/layout.tsx file is where the server-loaded translation resources are passed into the client-side i18n context.
The layout initializes the server-side i18n instance and obtains the loaded resources:
const { i18n } = await getT(); const resources = getResources(i18n);
Those resources are then passed to the provider:
<I18nProvider language={language} resources={resources} > {children} </I18nProvider>
The layout therefore establishes the translation context once for the entire language-specific part of the application.
Why this separation matters
With the App Router, we should generally default to Server Components.
The reason is simple: turning a component into a Client Component moves its logic into the browser and can add JavaScript that isn’t otherwise necessary.
Internationalization shouldn’t be the reason to do that.
If a component only needs to render translated content, it can remain a Server Component and use getT().
useT() should be reserved for components that already need to run on the client — for example, because they handle user interaction, browser events, local state, or other client-side behavior.
Formatting Prices
Internationalization is not limited to translating text. Numbers, dates, currencies, and other values often need to be formatted according to the user's language as well.
Prices are a simple example.
The same value:
1999.99
can be displayed differently depending on the language:
$1,999 1 999 zł 1.999 €
In this project, the language used in the URL is a short code such as en, pl, or de. However, the Intl APIs work with more specific locale identifiers, such as en-US or pl-PL.
The formatPrice utility handles this mapping:
const LOCALE_CURRENCY: Record<string, string> = { en: "USD", pl: "PLN", de: "EUR", }; const LOCALE_BCP47: Record<string, string> = { en: "en-US", pl: "pl-PL", de: "de-DE", };
There are two separate mappings here.
LOCALE_CURRENCY determines which currency should be displayed, while LOCALE_BCP47 determines the regional formatting conventions used by Intl.NumberFormat.
The actual formatting is then kept in a small utility:
export function formatPrice(price: number, language: string): string { const currency = LOCALE_CURRENCY[language] ?? "USD"; const bcp47 = LOCALE_BCP47[language] ?? "en-US"; return new Intl.NumberFormat(bcp47, { style: "currency", currency, maximumFractionDigits: 0, }).format(price); }
This gives components a simple API:
formatPrice(product.price, lng);
without requiring them to know anything about currencies, BCP 47 locale identifiers, or Intl.NumberFormat.
Localized Navigation
As language becomes part of the URL, navigation is one of the first areas where i18n can start leaking into your application code.
Without an abstraction, every link needs to be aware of the current language:
<Link href={`/${lng}/product/${id}`}> Product </Link>
This works, but it couples the component directly to the routing structure.
It also gets repetitive: every developer has to remember that the first URL segment represents the current language.
To avoid this, the project introduces a LocalizedLink component.
LocalizedLink
The idea is simple: components provide a path relative to the language root, and LocalizedLink adds the current language automatically.
Instead of:
<Link href={`/${lng}/product/${id}`}> Product </Link>
we can write:
<LocalizedLink href={`/product/${id}`}> Product </LocalizedLink>
If the current language is Polish, the resulting URL is:
/pl/product/1
For German, it becomes:
/de/product/1
This way, the component hides a routing detail that’s irrelevant to the component using it.
Switching between languages
The language switcher is a slightly different case.
Here, we want to change the language while preserving the current route.
For example, if the user is currently on:
/pl/product/1
switching to German should result in:
/de/product/1
rather than simply navigating to /de.
The switcher can therefore treat the current pathname as a resource-independent path and replace only the language segment.
Conceptually:
Current URL /pl/product/1 │ │ replace language ▼ /de/product/1
This is important for product pages because changing the language should not interrupt the user's current context.
Production Considerations
The implementation covers the core architecture, but a production application would still need to address a few additional concerns.
Caching translation resources
Translation resources are usually much more stable than application data. Fetching the same translations on every request would therefore be wasteful.
The resourceLoader is a natural place to introduce caching and revalidation:
Component ↓ next-i18next ↓ resourceLoader ↓ cache ↓ translation source
The exact strategy depends on where the translations are stored. With a CMS or external translation service, the application could use Next.js caching and revalidation to avoid unnecessary requests while still allowing updated translations to reach users without rebuilding the entire application.
SEO and hreflang
Language-specific URLs also make SEO an important consideration.
For example:
/en/product/1 /pl/product/1 /de/product/1
These URLs represent different language versions of the same page. A production application should expose this relationship to search engines using appropriate hreflang metadata.
Canonical URLs should also be generated consistently so that each language version points to itself as the canonical URL, while the hreflang links connect the available alternatives.
The routing architecture already provides a clear URL for each language version. SEO metadata can build on top of that structure instead of inferring the language from cookies or client-side state.
Conclusion
Building a multilingual application with Next.js isn’t just about translating strings. Language affects routing, request handling, resource loading, rendering, and navigation, so internationalization quickly becomes an architectural concern.
The implementation presented here addresses those concerns at well-defined boundaries. The language is part of the URL, Proxy handles language detection, next-i18next provides the translation layer, and Server and Client Components use different APIs depending on where translations are needed. Application-specific abstractions such as LocalizedLink then keep language-related details out of regular components.




