Implementing i18n in Web Applications: A Practical Guide
When I built my first side project, internationalization was an afterthought. The app was in English, and I figured I would "add Korean later." That decision cost me weeks of refactoring. Every hardcoded string, every date format assumption, every layout that broke with longer translated text — all of it had to be reworked. Since then, I have built i18n into every project from the start, and I want to share what I have learned.
Why i18n Matters, Even for Small Projects
Internationalization (i18n) is the process of designing your application so it can be adapted to different languages and regions without code changes. Localization (l10n) is the actual process of translating and adapting content for a specific locale.
Even if you are only targeting one language today, building with i18n in mind has practical benefits. It forces you to separate content from code. It makes your strings reusable. It prepares you for future expansion. And if your project is a web app or mobile app with global reach, supporting multiple languages can dramatically expand your potential user base.
For my projects, adding Korean support alongside English roughly doubled the addressable market. The effort was minimal because the architecture was already in place.
Approaches: Library vs Custom
There are two paths: use an established i18n library or build a custom solution.
Using a Library
For React projects, the main options are:
- next-intl — purpose-built for Next.js, excellent integration with App Router
- react-i18next — the most popular general-purpose i18n library for React
- FormatJS (react-intl) — ICU message format, strong internationalization standards
For Next.js projects specifically, I recommend next-intl. It integrates naturally with server components, handles routing, and provides type-safe translations.
Custom Solution
For simpler projects or when you want full control, a custom i18n system is surprisingly straightforward. In several of my apps, I use a pattern based on plain TypeScript maps:
// en.ts
export const en = {
"home.title": "Welcome",
"home.subtitle": "Start your journey",
"nav.settings": "Settings",
} as const;
export type TranslationKey = keyof typeof en;
// ko.ts
export const ko: Record<TranslationKey, string> = {
"home.title": "환영합니다",
"home.subtitle": "여정을 시작하세요",
"nav.settings": "설정",
};
This approach gives you type safety (you cannot reference a key that does not exist) and ensures every language has every key defined. The tradeoff is that you lose features like pluralization rules and ICU message formatting that libraries handle automatically.
Translation File Structure
How you organize translation files matters as your project grows. Here are two common approaches:
By Language (Recommended for Smaller Projects)
locales/
en.json
ko.json
ja.json
Each file contains all translations for that language. Simple to understand, easy to send to translators.
By Feature (Better for Large Projects)
locales/
en/
common.json
auth.json
dashboard.json
settings.json
ko/
common.json
auth.json
dashboard.json
settings.json
Splitting by feature enables code splitting — you only load the translations needed for the current page. This matters when your translation files grow to hundreds or thousands of keys.
Key Naming Conventions
Use dot-separated namespaces that reflect your app structure:
{
"auth.login.title": "Sign In",
"auth.login.email_placeholder": "Enter your email",
"auth.login.submit": "Sign In",
"auth.login.error.invalid_credentials": "Invalid email or password",
"dashboard.stats.total_reviews": "Total Reviews",
"dashboard.stats.streak_days": "{count} day streak"
}
Be consistent with naming conventions. I use feature.section.element as my pattern, with snake_case for multi-word segments.
Setting Up next-intl with Next.js App Router
Here is a practical setup for Next.js projects using next-intl:
// i18n/request.ts
import { getRequestConfig } from "next-intl/server";
export default getRequestConfig(async ({ locale }) => ({
messages: (await import(`../messages/${locale}.json`)).default,
}));
// middleware.ts
import createMiddleware from "next-intl/middleware";
export default createMiddleware({
locales: ["en", "ko"],
defaultLocale: "en",
});
export const config = {
matcher: ["/((?!api|_next|.*\\..*).*)"],
};
The middleware handles locale detection from the URL, redirects, and setting the appropriate locale for each request.
In your components, use the useTranslations hook:
import { useTranslations } from "next-intl";
export function LoginForm() {
const t = useTranslations("auth.login");
return (
<form>
<h1>{t("title")}</h1>
<input placeholder={t("email_placeholder")} />
<button type="submit">{t("submit")}</button>
</form>
);
}
Dynamic Content and Interpolation
Static translations are straightforward, but real apps have dynamic content — numbers, dates, user names, and plurals.
Variable Interpolation
{
"greeting": "Hello, {name}!",
"items_in_cart": "You have {count} items in your cart"
}
t("greeting", { name: user.name }); // "Hello, Bitnara!"
Pluralization
Different languages have different pluralization rules. English has two forms (singular and plural), but languages like Arabic have six. ICU message format handles this:
{
"notification_count": "{count, plural, =0 {No notifications} one {1 notification} other {{count} notifications}}"
}
If you are using a custom i18n solution without ICU support, you can handle basic pluralization with a helper function:
function plural(count: number, singular: string, pluralForm: string): string {
return count === 1 ? singular : pluralForm;
}
But be aware that this only works for English-like languages. If you support languages with complex plural rules, use a library that implements CLDR plural rules.
Date and Number Formatting
Never format dates or numbers manually for i18n. Use the Intl API, which is built into modern browsers and Node.js:
// Date formatting
const date = new Date("2026-01-15");
new Intl.DateTimeFormat("en-US").format(date); // "1/15/2026"
new Intl.DateTimeFormat("ko-KR").format(date); // "2026. 1. 15."
new Intl.DateTimeFormat("de-DE").format(date); // "15.1.2026"
// Number formatting
new Intl.NumberFormat("en-US").format(1234567.89); // "1,234,567.89"
new Intl.NumberFormat("de-DE").format(1234567.89); // "1.234.567,89"
new Intl.NumberFormat("ko-KR").format(1234567.89); // "1,234,567.89"
// Currency formatting
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(9.99); // "$9.99"
new Intl.NumberFormat("ko-KR", {
style: "currency",
currency: "KRW",
}).format(12000); // "₩12,000"
The Intl API handles locale-specific formatting rules automatically. Use it consistently throughout your app.
RTL (Right-to-Left) Support
If you support Arabic, Hebrew, Persian, or Urdu, you need RTL layout support. This affects your entire CSS layout.
The key principle is to use logical CSS properties instead of physical ones:
/* Physical (avoid for i18n) */
.sidebar {
margin-left: 20px;
padding-right: 10px;
text-align: left;
border-left: 1px solid gray;
}
/* Logical (i18n-friendly) */
.sidebar {
margin-inline-start: 20px;
padding-inline-end: 10px;
text-align: start;
border-inline-start: 1px solid gray;
}
Logical properties like margin-inline-start automatically flip for RTL languages. Set the dir attribute on your <html> tag based on the current locale:
<html lang={locale} dir={locale === "ar" ? "rtl" : "ltr"}>
Even if you do not plan to support RTL languages now, using logical CSS properties costs nothing and prepares your layout for the future.
URL-Based Locale
There are three common approaches to encoding the locale in the URL:
- Subpath:
example.com/en/about,example.com/ko/about - Subdomain:
en.example.com/about,ko.example.com/about - Query parameter:
example.com/about?lang=en
I recommend the subpath approach for most projects. It is the easiest to implement, works with any hosting setup, and is the most SEO-friendly according to Google's recommendations.
With Next.js and next-intl, subpath routing works out of the box. Your file structure mirrors the locale paths:
app/
[locale]/
page.tsx
about/
page.tsx
blog/
page.tsx
Language Detection
When a user first visits your site, how do you decide which language to show? Here is the order of priority I use:
- Explicit user preference (stored in cookie or user profile)
- URL locale (if using subpath routing)
- Accept-Language header (the browser's language preference)
- Default locale (your fallback language)
function detectLocale(request: Request): string {
// Check cookie first
const cookieLocale = getCookie(request, "preferred-locale");
if (cookieLocale && supportedLocales.includes(cookieLocale)) {
return cookieLocale;
}
// Check Accept-Language header
const acceptLanguage = request.headers.get("accept-language");
if (acceptLanguage) {
const preferred = acceptLanguage
.split(",")
.map((lang) => lang.split(";")[0].trim().substring(0, 2))
.find((lang) => supportedLocales.includes(lang));
if (preferred) return preferred;
}
return defaultLocale;
}
Always provide a visible language switcher in your UI. Do not rely solely on automatic detection — users should be able to override it easily.
Testing i18n
Testing internationalized applications requires checking more than just whether the right strings appear.
Visual testing: Translated text is often longer than English. German translations can be 30% longer. Japanese and Chinese are more compact. Check that your layouts handle text of varying lengths without breaking.
Screenshot testing: Use tools like Playwright or Chromatic to capture screenshots in each locale. This catches layout issues that are hard to spot manually.
Missing key detection: Set up a warning system that alerts you when a translation key is missing. In development, I render missing keys as [MISSING: key.name] in bright red so they are impossible to miss.
function t(key: TranslationKey): string {
const value = translations[currentLocale][key];
if (!value) {
console.warn(`Missing translation: ${key} for locale ${currentLocale}`);
return `[MISSING: ${key}]`;
}
return value;
}
Pseudo-localization: Before you have real translations, use pseudo-localization to test your i18n setup. This replaces each character with an accented equivalent (e.g., "Settings" becomes "[Seeettiiinngs]"), making it easy to spot hardcoded strings that were not routed through the translation system.
SEO for Multilingual Sites
Search engines need to understand the relationship between your translated pages. Use the hreflang tag to tell Google which language each page is in:
<link rel="alternate" hreflang="en" href="https://example.com/en/about" />
<link rel="alternate" hreflang="ko" href="https://example.com/ko/about" />
<link rel="alternate" hreflang="x-default" href="https://example.com/en/about" />
The x-default value tells search engines which page to show when no other locale matches.
Additional SEO considerations:
- Each locale should have its own URL (not just a query parameter toggle).
- Include the
langattribute on your<html>tag. - Submit separate sitemaps for each locale, or use a single sitemap with
hreflangannotations. - Do not automatically redirect based on IP geolocation — let Google's crawler see all versions.
Common Pitfalls
Concatenating translated strings. Never build sentences by concatenating translated fragments. Word order varies between languages. "5 items in cart" might be "cart in 5 items" in another language. Use interpolation instead.
Assuming text direction. Do not use CSS float: left or absolute positioning for layout elements that should flip in RTL languages. Use flexbox with logical properties.
Hardcoding date formats. "MM/DD/YYYY" is only used in the US. Most of the world uses "DD/MM/YYYY" or "YYYY-MM-DD." Use Intl.DateTimeFormat and let the locale handle it.
Forgetting about text in images. If you have text baked into images, you need separate images for each language. Better yet, use CSS or SVG to overlay text on images so it can be translated.
Not handling missing translations gracefully. In production, a missing translation should fall back to the default language, not crash the app or show an error.
Building i18n into your application from the start is one of those investments that pays dividends over time. The upfront cost is small — mostly just wrapping strings in a translation function and using the Intl API for formatting. The payoff is an application that can reach a global audience without a rewrite. Start early, use the right tools, and your future self will thank you.