How to Structure Your Translation Files and Routes
You picked an i18n library. You installed it, followed the quickstart, and got "Hello World" rendering in two languages. Now comes the part nobody talks about: how do you actually organize the translation files? And how do you set up routing so users land on the right locale?
These two decisions shape every i18n project. Get them wrong early and you'll spend weeks refactoring later. This guide covers both: file organization patterns that scale, and locale-based routing in Next.js.
File organization patterns
There are two common approaches to organizing translation files, and most teams pick one without thinking about it.
Per-locale directories
The most popular pattern is a directory per locale, each containing multiple files split by feature:
locales/
en/
common.json
dashboard.json
checkout.json
settings.json
fr/
common.json
dashboard.json
checkout.json
settings.json
de/
common.json
dashboard.json
checkout.json
settings.jsonThis works well with next-intl. You load messages by combining the locale directory with the namespace:
// i18n/request.ts
import { getRequestConfig } from "next-intl/server";
import { hasLocale } from "next-intl";
import { routing } from "./routing";
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale;
const locale = hasLocale(routing.locales, requested)
? requested
: routing.defaultLocale;
return {
locale,
messages: {
common: (await import(`@/locales/${locale}/common.json`)).default,
dashboard: (await import(`@/locales/${locale}/dashboard.json`)).default,
checkout: (await import(`@/locales/${locale}/checkout.json`)).default,
},
};
});The advantage here is that each file stays small. A developer working on checkout only opens checkout.json. PRs touch fewer lines, and merge conflicts are rare.
Single file per locale
The simpler approach is one file per locale:
locales/
en.json
fr.json
de.jsonThis is easier to set up and works fine for small apps. But once you cross a few hundred keys, the files get unwieldy. Diffs become noisy, and multiple developers editing the same file leads to merge conflicts.
When to split
Split your files when any of these are true:
- A single locale file exceeds 200 keys
- Multiple developers regularly edit translations at the same time
- Your app has distinct sections (dashboard, marketing site, checkout) maintained by different teams
A good rule: one file per feature area, plus a common.json for shared strings like button labels, error messages, and navigation items.
Flat vs nested keys
This is the debate that never ends. Both approaches work. The trade-offs are what matter.
Nested keys
Most i18n tutorials show nested structures:
{
"checkout": {
"shipping": {
"title": "Shipping Address",
"subtitle": "Where should we deliver?",
"form": {
"street": "Street",
"city": "City",
"zip": "ZIP Code"
}
},
"payment": {
"title": "Payment Method",
"cardNumber": "Card Number"
}
}
}Nested keys are easy to read and naturally group related strings. In your code, you reference them with dot notation: t("checkout.shipping.title").
The problems show up at scale. Deep nesting (four or five levels) makes files hard to navigate. Merge conflicts in nested JSON are painful because a missing bracket can break the entire tree. And renaming a key at a higher level means updating every child.
Flat keys
Flat keys use dot-separated strings at the top level:
{
"checkout.shipping.title": "Shipping Address",
"checkout.shipping.subtitle": "Where should we deliver?",
"checkout.shipping.form.street": "Street",
"checkout.shipping.form.city": "City",
"checkout.shipping.form.zip": "ZIP Code",
"checkout.payment.title": "Payment Method",
"checkout.payment.cardNumber": "Card Number"
}Flat keys produce cleaner diffs. Each key is a single line, so adding, removing, or changing a translation is a one-line change. Merge conflicts are trivial to resolve. Searching for a key with grep or ctrl+f works instantly.
The downside is readability. A flat file with 500 keys is a wall of text. There's no visual grouping, and it's harder to see which keys belong together.
What to pick
For small apps (under 100 keys), use nested. It's more readable and the downsides don't apply yet.
For larger apps, use flat keys or limit nesting to one level deep:
{
"checkout": {
"shippingTitle": "Shipping Address",
"shippingSubtitle": "Where should we deliver?",
"paymentTitle": "Payment Method"
},
"common": {
"save": "Save",
"cancel": "Cancel"
}
}One level of nesting gives you grouping without the merge conflict headaches.
Naming conventions that scale
Inconsistent key names are a silent productivity killer. Establish conventions early.
Group by feature, not by page
Page-based keys like homePage.hero.title break down when components are shared across pages. Feature-based keys are more stable:
{
"auth.loginButton": "Sign in",
"auth.logoutButton": "Sign out",
"auth.forgotPassword": "Forgot your password?",
"nav.home": "Home",
"nav.pricing": "Pricing",
"nav.blog": "Blog"
}A login button is an auth feature, regardless of which page it appears on.
Pick a casing and stick with it
The two common choices are camelCase (shippingTitle) and snake_case (shipping_title). Either works. What matters is consistency. Mix them and your developers will waste time guessing which convention a key uses.
Most JavaScript/TypeScript teams prefer camelCase since it matches the language's conventions.
Shared keys vs duplicated keys
Should "Save" have one key (common.save) used everywhere, or separate keys per feature (settings.save, checkout.save)?
Use shared keys for truly generic strings: button labels, form validation messages, and common UI elements. Duplicate keys when the text might diverge later. "Save" in settings and "Save" in checkout might need different labels as the product evolves ("Save preferences" vs "Place order").
Locale routing in Next.js
File organization is half the puzzle. The other half is routing: how does /about become /en/about and /fr/about?
Subpath routing vs subdomain routing
There are two approaches:
- Subpath:
example.com/en/about,example.com/fr/about - Subdomain:
en.example.com/about,fr.example.com/about
Subpath routing is simpler. It works with a single deployment, needs no DNS configuration, and consolidates SEO authority on one domain. Most apps should use subpath routing unless there's a specific reason to use subdomains (like region-specific content that needs separate hosting).
Setting up next-intl with subpath routing
next-intl provides a clean setup for locale-based routing. Here's the configuration:
First, define your supported locales:
// i18n/routing.ts
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["en", "fr", "de", "ja"],
defaultLocale: "en",
});Next, add middleware for locale detection and redirects:
// middleware.ts
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
export default createMiddleware(routing);
export const config = {
matcher: ["/((?!api|_next|.*\\..*).*)"],
};This middleware does three things:
- Detects the user's preferred locale from the
Accept-Languageheader - Redirects
/aboutto/en/about(or whatever the detected locale is) - Sets a cookie to remember the choice
Then, structure your app directory with a [locale] dynamic segment:
app/
[locale]/
layout.tsx
page.tsx
about/
page.tsx
pricing/
page.tsxIn your layout, wrap the app with the next-intl provider:
// app/[locale]/layout.tsx
import { NextIntlClientProvider, hasLocale } from "next-intl";
import { getMessages } from "next-intl/server";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}And in your pages, use the useTranslations hook:
// app/[locale]/page.tsx
import { useTranslations } from "next-intl";
export default function HomePage() {
const t = useTranslations("common");
return (
<main>
<h1>{t("heroTitle")}</h1>
<p>{t("heroSubtitle")}</p>
<button>{t("ctaButton")}</button>
</main>
);
}Putting it all together
Here's a complete folder structure that combines everything: per-locale directories, feature-based file splitting, and subpath routing with next-intl.
project/
locales/
en/
common.json # Shared strings (nav, buttons, errors)
marketing.json # Landing pages, hero sections
dashboard.json # App dashboard
checkout.json # Checkout flow
fr/
common.json
marketing.json
dashboard.json
checkout.json
src/
i18n/
routing.ts # Locale config
request.ts # Message loading
middleware.ts # Locale detection + redirect
app/
[locale]/
layout.tsx # next-intl provider
page.tsx # Marketing homepage
dashboard/
page.tsx
checkout/
page.tsxEach page loads only the namespaces it needs. The middleware handles locale detection. And the translation files stay organized by feature, not by page or component.
Keeping it manageable
The file structure and routing setup covered here will carry most projects through their first few thousand keys and a dozen locales. But as your translation files grow, editing them by hand becomes the bottleneck. Tools like SkyBlobs give you a visual editor on top of these same files, letting you browse keys, compare locales side by side, and push changes through your normal Git workflow.
Whatever tools you use, the foundation matters. Get your file organization and routing right from the start, and everything else gets easier.