Your translations are your types.
Native I18n keeps each translation namespace as ordinary, serializable TypeScript data. Locale and namespace loaders are explicit async boundaries; TypeScript derives the callable translation contract from the fallback locale.
There is no schema generation, message compiler, catalog-defined function, or runtime validation dependency. Standard message and Intl helpers create pure recipe objects, and Native I18n materializes those recipes for the resolved locale at runtime.
npm install native-i18nReact integration requires React 19 or newer. Next.js App Router integration requires Next.js 15 or newer.
Declare every locale and namespace with a statically analyzable loader:
// i18n/resources.ts
import {defineResources} from "native-i18n"
export const resources = defineResources({
fallbackLocale: "en-US",
loaders: {
"en-US": {
common: () =>
import("./messages/en-US/common").then(m => m.default),
home: () => import("./messages/en-US/home").then(m => m.default)
},
"zh-Hant": {
common: () =>
import("./messages/zh-Hant/common").then(m => m.default),
home: () => import("./messages/zh-Hant/home").then(m => m.default)
}
}
})Every locale must expose exactly the fallback locale's namespace names. A loader
may return data synchronously, but dynamic import() is recommended for
translation modules because it gives the bundler an explicit locale × namespace
loading boundary.
Use the fallback module as the authoring contract for each translation:
// messages/zh-Hant/home.ts
import {insert, plural} from "native-i18n"
export default {
title: "首頁",
welcome: insert("歡迎,{{name}}!", {name: String}),
items: plural({other: insert("{{value}} 件物品")})
} satisfies typeof import("../en-US/home").defaultNative I18n owns namespace resolution, loading, deduplication, caching,
transport, and typing. How an application divides its copy is an application
decision. A useful default is a small common namespace plus route or feature
namespaces. Avoid turning common into the whole application, and avoid tiny
namespaces that are always requested together.
These mechanisms solve different problems:
- ESM tree-shaking removes unused library exports from a bundle. Native I18n
publishes ESM entry points and declares
sideEffects: falseto support it. - Namespace loading keeps translation modules out of the initial execution path until their loader is requested.
Calling getTranslation("home") loads the complete selected namespace; it does
not remove unused keys within that namespace. Each explicit dynamic import is an
async module boundary, although the final number and names of physical chunks
remain a bundler decision.
A component declares every namespace it reads directly at the hook call:
function FeedCard() {
const {t} = useTranslation(["engagement", "feed", "ui"])
return <button aria-label={t.ui.like}>{t.engagement.likes(3)}</button>
}The factory knows the resource registry, so editors autocomplete every string in
the array and TypeScript preserves the exact tuple without as const. This
literal form is the preferred API: it keeps the dependency beside the consumer
and gives future build tooling a statically analyzable boundary.
Use defineTranslationBundle only when several consumers intentionally share
the same namespace set:
const {defineTranslationBundle} = create(resources)
export const feedNamespaces = defineTranslationBundle([
"engagement",
"feed",
"ui"
])It performs no loading and returns the same exact tuple. A type-only module can define a bundle without importing the runtime registry:
import {defineTranslationBundle} from "native-i18n"
import type {resources} from "./resources"
const defineBundle = defineTranslationBundle<typeof resources>()
export const feedNamespaces = defineBundle(["engagement", "feed", "ui"])import {create} from "native-i18n"
import {resources} from "./i18n/resources"
const i18n = create(resources, {timeZone: "UTC"})
const {t, locale} = await i18n.getTranslation(
["common", "home"],
["zh-Hant", "en-US"]
)
t.common.back
t.home.welcome({name: "Ada"})
locale.current // "zh-Hant"A string selection scopes t directly to that namespace. An array selection
returns an object keyed by namespace. Multiple namespaces load concurrently and
are cached by resolved locale plus namespace. Failed loads are not retained, so
a later request can retry.
Locale matching uses normalized BCP 47 tags and best-fit matching. It never loads an entire fallback catalog eagerly: the fallback locale is metadata and uses the same namespace loaders as every other locale.
Static translations stay plain values. Standard helpers return serializable recipe nodes, not functions:
import {currency, insert, plural} from "native-i18n"
export default {
title: "Account",
welcome: insert("Welcome, {{name}}!", {name: String}),
files: plural({one: "one file", other: insert("{{value}} files")}),
price: currency("USD")
}After loading, Native I18n materializes the same shape with strongly typed callables:
t.welcome({name: "Ada"})
t.files(2)
t.price(12)Catalog-defined JavaScript functions are deliberately unsupported, recursively:
defineResources({
fallbackLocale: "en",
loaders: {en: {common: () => ({message: (name: string) => name})}}
})
// TypeScript error; runtime validation also rejects it.This single pure-data model makes namespaces safe to cache, serialize through React Server Components, inspect, persist, and hydrate without executing catalog code. It also removes the former distinction between specially branded functions and transport recipes.
insert parses a deliberately small Pattern subset: variable tags and
set-delimiter tags are supported; sections, partials, comments, dotted names,
HTML escaping, and unescaped-variable tags are rejected.
import {asValue, insert, number, plural} from "native-i18n"
const files = plural(
{
one: insert("{{name}} has one file"),
other: insert("{{name}} has {{count}} files")
},
{name: String, count: asValue(number())}
)Message nodes compose as a tree. Use insert() for branch text containing
Pattern variables, asValue() to name a choice selector, unused() to retain a
parameter only for contract parity, and value<T>() for values such as
ReactNode that must not be stringified.
plural and ordinal use Intl.PluralRules; exact =n cases win before
category selection and other is required. select uses string keys. range
selects the first inclusive range. A bare # has no special meaning.
| Area | Helpers |
|---|---|
| Numbers | number, integer, currency, percent, unit, compact |
| Date/time | date, time, datetime |
| Relative/duration | relativeTime, duration |
| Composition | list, displayName |
The helpers follow native Intl semantics. In particular, percent() treats
0.25 as 25%, relativeTime requires an explicit unit, and date/time helpers
use the configured time zone (UTC by default) for deterministic server/client
output. duration requires Intl.DurationFormat or a standards-compliant
polyfill.
For a client-rendered React application, give the client factory the runtime resource registry:
// i18n.ts
import {create} from "native-i18n/react"
import {resources} from "./resources"
export const {TranslationProvider, preload, useLocale, useTranslation} =
create(resources)function Page() {
const {t, locale} = useTranslation("home")
return <h1 lang={locale.current}>{t.welcome({name: "Ada"})}</h1>
}useTranslation(selection, {tags}) and core/server getTranslation are thin
adapters over the same namespace resolver and return the same data, t, and
locale model. They remain separate APIs because one is a React hook and the
other is asynchronous server/framework code.
When a loader-backed client requests a namespace that is not cached, the hook
reads its cached Promise with React use, activating the nearest Suspense
boundary. Put that boundary around the smallest UI region that needs lazy
translations:
<Suspense fallback={<PageSkeleton />}>
<Page />
</Suspense>There is no suspense switch and no provider-wide boundary. Concurrent requests
for the same locale and namespace share one pending load. preload(selection)
uses the same cache and can warm a feature on hover or before a transition.
TranslationProvider initial={snapshot} hydrates data already loaded by the
server, but the snapshot is a cache seed, not a declaration of every namespace
that descendants may use. A descendant can add a namespace to its literal
selection safely; only that missing locale × namespace pair is loaded.
Property access is the primary API. t also supports typed string paths when a
translation key genuinely needs to be passed as data:
t.items.apple
t("items.apple")Server code loads only the namespaces it needs and passes the returned snapshot, never the materialized server result, to a Client Component:
// i18n/server.ts
import {create} from "native-i18n/react/server"
import {resources} from "./resources"
export const {getTranslation} = create(resources)Use the loader-backed client when Client Components can declare namespaces that were not included in the server snapshot:
// i18n/client.ts
"use client"
import {create} from "native-i18n/react/client"
import {resources} from "./resources"
export const {TranslationProvider, preload, useLocale, useTranslation} =
create(resources)const {snapshot} = await getTranslation("common", ["zh-Hant"])
return <TranslationProvider initial={snapshot}>{children}</TranslationProvider>Snapshots contain only the selected namespace data and execution context. Nested providers share the same locale × namespace cache, so a route can seed additional namespaces without resending those already available.
native-i18n/react/seeded remains available for a deliberately closed client
subtree whose runtime registry must stay server-only. It accepts only the
resource type and throws NativeI18nNamespaceError when a consumer asks for an
unseeded namespace. Treat that entry as an explicit size-versus-resilience
tradeoff, not the default correctness mechanism.
Keep the resource registry client-safe: it should contain defineResources and
explicit dynamic imports, but no secrets, Node-only APIs, or server-only
marker. The registry itself is small; Next emits the translation modules behind
its locale × namespace dynamic-import boundaries.
// app/i18n/resources.ts
import {defineResources} from "native-i18n"
export const resources = defineResources({
/* explicit locale × namespace dynamic imports */
})// app/i18n/server.ts
import {create} from "native-i18n/next/server"
import {resources} from "./resources"
export const {getLocaleTags, getTranslation, matchLocale, preload} =
create(resources)The client receives the same registry so an omitted server seed can be recovered locally:
// app/i18n/client.ts
"use client"
import {create} from "native-i18n/next/client"
import {resources} from "./resources"
export const {
TranslationProvider,
preload,
useLocale,
useSetLocale,
useTranslation
} = create(resources)Seed namespaces that are already known at the server boundary. This avoids a
client request for the normal path without making the layout responsible for the
complete dependency graph. For example, a root layout can provide a small
common namespace while a page reads home exclusively on the server:
// app/layout.tsx
const {locale, snapshot} = await getTranslation("common")
return (
<html lang={locale.current}>
<body>
<TranslationProvider initial={snapshot}>
{children}
</TranslationProvider>
</body>
</html>
)// app/page.tsx — Server Component
const {t} = await getTranslation("home")
return <h1>{t.title}</h1>If a Client Component below that layout later calls
useTranslation(["common", "engagement", "feed"]), the seeded common data is
reused and the other namespaces load concurrently through the nearest Suspense
boundary. The server seed remains a performance optimization rather than a
fragile coverage contract.
For an intentionally closed subtree, native-i18n/next/seeded preserves the old
type-only, zero-loader client graph:
"use client"
import {create} from "native-i18n/next/seeded"
import type {resources} from "./resources"
export const {TranslationProvider, useLocale, useSetLocale, useTranslation} =
create<typeof resources>()This strict entry is synchronous, but any namespace absent from the provider snapshot is a configuration error. Prefer the loader-backed default for shared layouts and evolving feature trees.
useSetLocale() writes the locale cookie and performs router.refresh() in a
transition, returning {isPending, setLocale}.
getLocaleTags() checks NEXT_LOCALE before Accept-Language. Set
cookieName: false to disable cookie locale selection and omit useSetLocale
from the client factory result.
Low-level pure-data tools live at native-i18n/ast:
import {compile, describe, hydrate, validateData} from "native-i18n/ast"compile(recipe, context)materializes one recipe.hydrate(data, context)recursively materializes a namespace or snapshot.validateData(data)verifies the pure serializable catalog contract.describe(recipe)returns a readable representation for tooling.
Unknown recipe operations, custom functions, circular structures, non-finite numbers, non-plain objects, and symbol-keyed data are rejected.
| Example | Focus |
|---|---|
native-basic |
Framework-free locale × namespace loading. |
react-basic |
Client loaders, localized Suspense, and multi-namespace selection. |
next-basic |
Client-safe lazy registry, selective RSC seeds, recoverable client namespaces, and locale transitions. |
kitchen-sink |
Complete standard message and Intl recipe surface. |
yarn test # runtime, integration, and conformance tests
yarn test:types # authoring contracts and negative TypeScript cases
yarn test:examples # package build plus every consumer build
yarn verify # all of the abovePublishing runs the complete yarn verify gate.
MIT