Skip to content

luannzin/better-intl

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

42 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

🌐 better-intl

The simplest way to build internationalization in Next.js β€” fully typed, file-based, zero runtime.

Stop managing translation files. Stop jumping between locales. Just colocate translations with your features and import a single t.

TypeScript Node.js License

⚑ The idea

Instead of splitting translations across files per language:

/locales/en.json
/locales/pt.json
/locales/es.json

You write translations next to the feature they belong to, once:

// app/homepage/t.ts
export default {
  title: { en: "Hello {name}", pt: "OlΓ‘ {name}" },
  hero: {
    subtitle: { en: "Welcome", pt: "Bem-vindo" },
  },
}

That’s it.

You think in features, not files. You think in meaning, not locales.


πŸš€ What you get

From those t.ts files, better-intl generates a fully typed translation tree:

import { t } from "@/i18n/generated"

t.homepage.title({ name: "Ada" }) // "OlΓ‘ Ada"
t.homepage.hero.subtitle          // "Bem-vindo"

No hooks

No providers

No runtime i18n library

No string keys

No missing translations at runtime

Just TypeScript + generated code.


🧠 Mental model

better-intl does one thing:

It transforms your t.ts files into a single typed translation object grouped by feature path.

Pipeline:

t.ts files (feature-based)
        ↓
scan project structure
        ↓
transpose locales into keys
        ↓
generate typed module
        ↓
you import a single `t`

The file system becomes your i18n structure.


βš™οΈ Setup (Next.js)

1. Install

bun add better-intl

2. Scaffold

bunx --bun better-intl init

init writes intl.config.ts, runs the first generate. It is idempotent β€” existing files are left untouched. The two steps below (the Next plugin and the layout setLocale) are the wiring init can't do for you β€” both are required.


3. Add the plugin

// next.config.ts
import { withInternationalization } from "better-intl/next"

export default withInternationalization({
  reactStrictMode: true,
})

That’s it. No webpack plugins. No runtime setup.


4. Configure (optional)

init already wrote a working intl.config.ts. Tweak it as you grow β€” add locales, fallback chains, change the scan root:

// intl.config.ts
import type { I18nUserConfig } from "better-intl"

export default {
  root: "./src",
  out: "./src/i18n/generated.ts",

  defaultLocale: "en",
  locales: ["en", "pt", "es"],

  onMissing: "warn",

  fallback: {
    es: ["pt"],
  },

  storage: {
    type: "cookie",
    key: "locale",
  },
} satisfies I18nUserConfig

5. Import t

The generated module already exports everything bound to your translations and config β€” there's no lib/i18n wiring to write. Import t straight from it:

import { t, setLocale, updateLocale } from "@/i18n/generated"

t is the active locale's slice (sync on both client and server), setLocale() fills the per-request locale once in your root layout, and updateLocale(locale) persists a new preference to the cookie. They are produced by createI18n, which the generator calls for you.


6. Initialize locale once (App Router)

Resolve the locale once per request and stamp it on <html lang>. setLocale() fills the server store and returns the locale β€” the client reads that lang first, so its first render matches the server HTML and there's no locale flash.

// app/layout.tsx
import { setLocale } from "@/i18n/generated"

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const locale = await setLocale()

  return (
    <html lang={locale}>
      <body>{children}</body>
    </html>
  )
}

🧩 Usage

Define translations next to your feature

// src/components/header/t.ts
export default {
  greeting: { en: "Hello {name}", pt: "OlΓ‘ {name}" },
  nav: {
    home: { en: "Home", pt: "InΓ­cio" },
  },
}

Use anywhere (server or client)

import { t } from "@/i18n/generated"

export function Header() {
  return (
    <header>
      <h1>{t.components.header.greeting({ name: "Ada" })}</h1>
      <a>{t.components.header.nav.home}</a>
    </header>
  )
}

No hooks. No async. No context.


🌍 Locale resolution

better-intl resolves locale in this order:

  1. user stored preference (cookie)
  2. browser / Accept-Language
  3. fallback chain
  4. defaultLocale

Region-aware matching is supported:

pt-BR β†’ pt

πŸ” Fallback system

If a translation is missing:

locale β†’ fallback β†’ defaultLocale

Control behavior:

mode behavior
error fail build
warn log + fallback (default)
silent fallback silently

πŸ§ͺ Why this exists

Traditional i18n forces you to think like this:

β€œWhich file contains this string?”

better-intl flips it:

β€œWhich feature contains this meaning?”


πŸ”₯ Compared to existing solutions

next-intl / i18next / formatjs

Problem better-intl
runtime translation lookup ❌ removed
string keys ❌ removed
provider/hook boilerplate ❌ removed
missing translations at runtime ❌ impossible
feature-based organization βœ… built-in
full type inference βœ… native

🧠 What makes it different

  • Feature-based colocation (t.ts)
  • Build-time transformation
  • Fully typed translation tree
  • Zero runtime dependency
  • Synchronous usage everywhere
  • Token-aware typing ({ name })
  • Next.js-first design

πŸ“¦ Configuration reference

option default description
root ./app scan root
out generated.ts output file
defaultLocale en fallback
locales inferred supported
onMissing warn missing handling
fallback {} fallback map
storage cookie persistence

🧭 Philosophy

better-intl is built on one belief:

Localization should follow your codebase structure β€” not fight it.

About

🌐 The easiest and most intuitive internationalization framework for Next.js.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors