A few days ago I had a conversation with Aymeric, the creator of Intlayer, about performance challenges in i18n tooling for Next.js. The main topics were client bundle size, namespace splitting, and how build-time tools can generate code that takes full advantage of the framework's optimizations.
That conversation led me to audit the code translate-kit generates against the next-intl documentation. I found four significant gaps. This post documents what I found, what I built, and the reasoning behind each decision.
The problem: generated code that ignores runtime optimizations
translate-kit is a build-time translation SDK. Its pipeline is scan → codegen → translate: it scans source code for translatable strings, generates semantic keys with AI, transforms the AST to replace strings with t() calls, and translates to target locales.
The issue wasn't with translation itself — it was with how the integration code for next-intl was generated. I identified four gaps, ordered by impact:
1. useTranslations() without a namespace (high impact)
The codegen was producing:
const t = useTranslations(); // loads ALL messagesWhen the next-intl best practice is:
const t = useTranslations("hero"); // loads only the "hero" namespaceWithout a namespace, next-intl can't tree-shake messages. In an app with 500 keys, every component loads all 500 even if it only uses 3.
2. NextIntlClientProvider sending everything to the client (high impact)
The layout generated by init did:
const messages = await getMessages();
// ...
<NextIntlClientProvider messages={messages}>This serializes all messages into the HTML and sends them to the client bundle. If your app has 200 server-component keys and 30 client-component keys, the client receives all 230.
3. No TypeScript types for message keys (medium impact)
No type declarations were generated. t("hero.welcome") had no autocomplete and no compile-time validation. A typo in a key would pass silently until runtime.
4. A single JSON per locale (medium impact)
All translations went into a single en.json. There was no way to code-split by route or feature.
The four optimization phases
Phase 1: TypeScript type safety
The simplest and most self-contained change. After writing the source JSON, translate-kit now automatically generates a next-intl.d.ts file:
import messages from "./en.json";
declare module "next-intl" {
interface AppConfig {
Messages: typeof messages;
}
}This gives next-intl the type information it needs so that t("hero.welcome") gets full autocomplete and flags errors if the key doesn't exist. It's generated automatically on every scan — no extra configuration needed.
Phase 2: Namespaces in useTranslations / getTranslations
This was the biggest change. The idea is simple: if all keys in a component share the same prefix (namespace), use useTranslations("hero") instead of useTranslations(), and rewrite the keys to strip the prefix.
The implementation required three steps in the AST transform:
1. Key tracking per component. During AST traversal, every time a string is replaced with t("hero.welcome"), the key is recorded in a Set<string> associated with the current component name.
2. Dominant namespace detection. A detectNamespace(keys) function extracts the first segment of each key. If all keys share the same prefix, it returns it. If there's a mix (e.g. hero.welcome + common.save), it returns null and falls back to no namespace. Conservative approach: it only assigns when it's unanimous.
3. Key rewriting. After injecting useTranslations("hero"), a second traversal pass transforms t("hero.welcome") into t("welcome"). It only rewrites keys that start with the assigned namespace.
The result:
// Before (generated by translate-kit)
import { getTranslations } from "next-intl/server";
export default async function HeroSection() {
const t = await getTranslations();
return <h1>{t("hero.welcome")}</h1>;
}
// After
import { getTranslations } from "next-intl/server";
export default async function HeroSection() {
const t = await getTranslations("hero");
return <h1>{t("welcome")}</h1>;
}Edge cases handled:
- Keys without a dot (
greeting): no namespace assigned - Keys with 3+ levels (
settings.profile.title): namespace =settings, relative key =profile.title - Re-execution: existing
useTranslations()detection prevents duplicating the declaration
Phase 3: Selective message passing with pick()
With namespaces from Phase 2 resolved, I now know exactly which namespaces each client component uses. The codegen tracks this in a clientNamespaces: string[] returned as part of the result.
After codegen, the layout is updated automatically:
// Before
const messages = await getMessages();
<NextIntlClientProvider messages={messages}>
// After
const messages = await getMessages();
const clientMessages = pickMessages(messages, ["hero", "auth", "common"]);
<NextIntlClientProvider messages={clientMessages}>pickMessages is a local helper generated automatically in the layout that filters only the needed namespaces. Only namespaces actually used by client components get serialized and sent to the bundle. If your app has 15 namespaces but only 3 are used in client components, the client payload shrinks proportionally.
Phase 4: Per-namespace JSON splitting (opt-in)
This phase adds the splitByNamespace: true configuration option. When enabled, instead of writing a single messages/en.json, the pipeline writes:
messages/en/hero.json → { "welcome": "Welcome", "getStarted": "Get started" }
messages/en/common.json → { "save": "Save", "cancel": "Cancel" }
messages/en/auth.json → { "signIn": "Sign in", "signOut": "Sign out" }Each namespace is an independent file. The diff/lock system (which uses SHA256 hashes for incremental translation) operates on flat keys, so it required no changes. Only the reader and writer branch based on the configuration.
To support this mode, i18n/request.ts generates a variant that reads and merges namespace files dynamically:
async function loadNamespaceMessages(dir: string) {
const files = await readdir(dir);
const messages: Record<string, unknown> = {};
for (const file of files.filter(f => f.endsWith(".json"))) {
const ns = file.replace(".json", "");
messages[ns] = JSON.parse(await readFile(join(dir, file), "utf-8"));
}
return messages;
}Bonus: namespace-aware semantic key generation
There's an upstream problem that can sabotage everything above: if the AI generates keys with inconsistent namespaces within the same component, detectNamespace returns null and it falls back to no namespace.
For example, if a Hero component has "Welcome" and "Get Started", and the AI generates hero.welcome + common.getStarted, the component can't use useTranslations("hero").
I implemented three improvements in the key generation prompt (key-ai.ts):
1. Explicit namespace-per-component rules. The prompt now includes a critical rule instructing the AI: all strings from the same React component must share a namespace. It explains that the target is useTranslations("namespace") from next-intl, which scopes all t() calls to that namespace.
2. Per-file grouping in the prompt. Instead of listing strings flat, they're grouped under file headers:
--- File: src/app/dashboard/page.tsx (route: dashboard) ---
[0] "Welcome back", component: DashboardPage, tag: h1
[1] "Your recent activity", component: DashboardPage, tag: h2
--- File: src/components/hero/Hero.tsx ---
[2] "Get started today", component: Hero, tag: h1This gives the AI clear visual context for the scope of each string.
3. Existing key context. Up to 30 entries from the existingMap are passed to the prompt so the AI maintains consistency with already-assigned namespaces.
4. Pre-batch sorting by file. Strings are sorted by (file, componentName) before splitting into batches, maximizing the probability that all strings from a component land in the same batch where the AI can see them together.
I also relaxed the common.* rule: previously, any generic string ("Save", "Cancel") would automatically go to common.. Now, common.* is only used when a string truly is generic and appears across multiple unrelated components. If "Save" only appears in SettingsForm, the key is settings.save, not common.save.
Measured impact
| Optimization | Metric | Change |
|---|---|---|
Namespace in useTranslations | Keys loaded per component | Only namespace keys vs. all |
Selective pick() | NextIntlClientProvider payload | Only client-needed namespaces |
| Per-namespace split | Message files | 1 monolithic → N granular files |
| Type safety | Key errors in dev | Compile-time vs. runtime |
| Improved semantic keys | Components with namespace | Higher % of optimized components |
The real impact depends on app size. In an app with 20 namespaces where only 4 are used in client components, the provider payload drops by ~80%. In an app where every component uses a consistent namespace (thanks to the key generation improvements), virtually all components benefit from scoping.
What's next
These optimizations cover the most significant gaps I identified. There are still opportunities like per-route lazy loading of namespaces (for very large apps) and deeper integration with the next-intl compiler to eliminate production overhead. But the foundation is in place: the code generated by translate-kit now respects next-intl best practices from the first scan.
Special thanks to Aymeric, creator of Intlayer, for the conversation that kicked off this entire audit. His insights on the performance challenges of i18n tooling in Next.js were the catalyst for these improvements.