·7 min read

How to Structure Your Translation Files and Routes

i18nnextjstranslationsrouting

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.json

This 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.json

This 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:

  1. Detects the user's preferred locale from the Accept-Language header
  2. Redirects /about to /en/about (or whatever the detected locale is)
  3. 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.tsx

In 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.tsx

Each 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.

Try SkyBlobs free

Connect your GitHub repo and start editing content visually. No setup, no config files.