DEV Community

Sam Dreams Maker
Sam Dreams Maker

Posted on

Internationalization in Next.js 16: Lessons From Supporting 10 Languages

When I decided to support 10 languages in TaleForge from day one, I knew it would be work. I didn't know it would be this much work. Here's everything I learned.

The Setup

Stack: Next.js 16 App Router + next-intl. Why next-intl? It plays nicely with server components, supports namespaced messages, and handles pluralization rules per locale.

Languages supported: English, French, Spanish, German, Portuguese, Italian, Japanese, Chinese, Arabic, Hebrew.

Yes, that includes two RTL (right-to-left) languages. Yes, that was painful.

Message Organization

With 10 pages, dozens of components, and modals/forms everywhere, I quickly had hundreds of translation keys. Organization was critical.

I settled on one JSON file per locale with nested namespaces:

{
  "nav": {
    "home": "Home",
    "pricing": "Pricing",
    "blog": "Blog"
  },
  "landing": {
    "hero": {
      "title": "Stop fighting with tools to write your book",
      "subtitle": "A writing platform that works the way you think"
    }
  },
  "editor": {
    "save": "Save",
    "wordCount": "{count, plural, =0 {No words} one {1 word} other {# words}}"
  }
}
Enter fullscreen mode Exit fullscreen mode

The namespace convention: page.section.key. This makes it easy to find keys and keeps related translations together.

The 1,326 Keys Problem

At last count, TaleForge has 1,326 translation keys across 10 locales. That's 13,260 individual translations.

How do you manage this without going insane?

  1. Extract keys as you build. Never hardcode strings, even "just for now." Future you will forget.

  2. Batch translations by feature. When building the manga editor, I wrote all English strings first, then translated the entire batch. Context helps — translating strings in isolation leads to weird results.

  3. Use ICU message format. {count, plural, one {1 project} other {# projects}} handles pluralization correctly across languages. Japanese doesn't have plural forms. Arabic has six. ICU handles it.

  4. Audit regularly. I built a script that checks for missing keys across locales. Run it in CI. Missing translations should break the build.

RTL Support

Arabic and Hebrew read right-to-left. This affects:

  • Text direction (obviously)
  • Layout direction (sidebars swap sides)
  • Icon direction (arrows flip)
  • Number formatting
  • Calendar direction

The CSS approach: use logical properties everywhere.

/* Don't do this */
.sidebar { margin-left: 20px; }

/* Do this */
.sidebar { margin-inline-start: 20px; }
Enter fullscreen mode Exit fullscreen mode

Logical properties (margin-inline-start, padding-inline-end, border-block-start) automatically flip in RTL mode. If you're starting a new project, use them exclusively. The browser support is excellent.

For the dir attribute, I set it on the <html> tag based on the current locale:

const rtlLocales = ['ar', 'he'];
const dir = rtlLocales.includes(locale) ? 'rtl' : 'ltr';
Enter fullscreen mode Exit fullscreen mode

Date and Number Formatting

Never format dates or numbers manually. Use Intl.DateTimeFormat and Intl.NumberFormat.

// Dates
new Intl.DateTimeFormat('ja', { 
  dateStyle: 'long' 
}).format(date);
// → "2026年4月3日"

// Numbers  
new Intl.NumberFormat('de', {
  style: 'currency',
  currency: 'USD'
}).format(9.99);
// → "9,99 $"
Enter fullscreen mode Exit fullscreen mode

The Intl APIs handle locale-specific formatting rules automatically. Arabic uses Eastern Arabic numerals (٠١٢٣). Japanese dates use year-month-day. German uses comma for decimals. Don't reinvent this.

Dynamic Content

Static UI text is straightforward. Dynamic content — user-generated stories, comments, reviews — is trickier.

I don't translate user content. The platform UI adapts to the user's language, but stories are displayed in whatever language the author wrote them in. This is a deliberate choice: machine-translating creative writing produces terrible results.

What I do translate: metadata. Genre names, status labels ("Published," "Draft"), system notifications. These come from a fixed set and can be pre-translated.

Performance Considerations

10 locale files × 1,326 keys = a lot of JSON. Loading all of it on every page would be wasteful.

next-intl supports message splitting — only load the messages needed for the current page. With Next.js App Router, each layout or page can specify which namespaces it needs:

export default async function PricingPage() {
  const t = await getTranslations('pricing');
  // Only 'pricing' namespace is loaded
}
Enter fullscreen mode Exit fullscreen mode

This keeps the client bundle lean. The full message files are only used server-side.

Testing

I test i18n with two approaches:

  1. Snapshot tests for critical pages in each locale. This catches layout breaks (German words are notoriously long) and missing translations.

  2. Visual regression for RTL layouts. A sidebar that looks perfect in English might overlap content in Arabic.

The most common bug: hardcoded strings that bypass the translation system. A code review checklist item: "Are there any raw strings in the JSX?"

Lessons Learned

  • Start with i18n on day one. Retrofitting is 10x harder than building it in.
  • RTL is not just direction: rtl. It touches layout, icons, animations, and user expectations.
  • Professional translation > machine translation for user-facing text. But machine translation is fine for a first pass that you refine.
  • 10 languages is probably too many to start with. English + 2-3 major languages would have been more practical. I added Japanese, Chinese, Arabic, and Hebrew because the writing community is global — but maintaining 10 locales is a real cost.

The result: about 40% of TaleForge's traffic comes from non-English speakers. For a writing platform, that's significant.


TaleForge — free creative writing platform supporting 10 languages

Top comments (0)