i18n Message Splitting — Step-by-Step TODO
Status: Deprioritized — measurement-gated · Author: AI Review · Date: 2026-07-06 · Reviewed: 2026-07-07
Standalone work item for
performance-improvements-todo.md
§2.1. Verified against the next-intl 4.13.1 docs and package source.
Is this worth doing? (assessment, 2026-07-07)
Marginal — don't start until real-user metrics prove a payload problem.
- The win is smaller than the raw numbers suggest. Only the active
locale's catalog ships (~57–66 KB raw), and it travels brotli/gzip-
compressed — the realistic saving is ~10–15 KB on the wire per page
load / navigation. It helps hydration/INP slightly; it does nothing for
cold-load LCP, which was fixed server-side (see
lcp-strategy.md). - The cost is permanent maintenance, with a user-visible failure mode.
A curated
APP_WIDE_NAMESPACESlist every future feature must remember; the atomic-merge gotcha in every nested provider; a forgotten namespace shows raw message keys in production, per locale, caught only by manual click-through. useExtracted(next-intl 4.5+) does not change this. It is an authoring/extraction API — delivery is unchanged (whole per-locale catalog through the provider). Automatic per-route tree-shaking remains next-intl's open research issue (next-intl#1). Migrating would rewrite ~101 call-site files and re-key all 10 locale catalogs for a few KB of minified-key savings — not worth it here.- If metrics ever justify acting, do the cheap ~20% version first:
keep the global provider, subtract only the 3 biggest route-locked
namespaces (
templateEditor.*~10 KB,gforms,turnstile— ~15 KB combined), and add nested providers on just those routes. Most of the win, tiny blast radius. The full plan below is the escalation path, not the starting point.
Problem
src/app/[locale]/layout.tsx passes the full locale catalog (60–68 KB,
messages/*.json) to NextIntlClientProvider, so every page embeds all
39 namespaces in its RSC payload — on first load and on every client
navigation. Most routes use a fraction of it.
Facts that shape the fix
- No automatic solution exists. Compiler-driven message tree-shaking is
an open next-intl research issue (
next-intl#1).useExtracteddoesn't auto-split;precompile: true(already enabled) only speeds up formatting — neither changes which messages ship. - Inheritance gotcha: a
NextIntlClientProviderrendered from a Server Component auto-inherits the full catalog from request config. Removing themessagesprop changes nothing — you must pass an explicit subset (ornull). - Nested providers inherit
locale/timeZone/etc. from ancestors, butmessagesis atomic: an inner provider's messages replace, not merge — include everything its subtree needs. - 96 of 101
useTranslationscall-site files are client components, so the win comes from splitting by route, not from moving translation server-side.
Step 1 — Build the namespace → routes map
Inventory which namespaces each route group's client components use:
grep -rn 'useTranslations("' src --include="*.tsx" -o | sort -u
For each namespace, record where it's needed. Starting point from the
initial inventory (sizes from en.json, 53 KB total):
| Namespace | Size | Scope |
|---|---|---|
common | ~4.5 KB | app-wide (61 call sites) |
MainNavBar, DrawerList, Footer, incidents, newTenantModal, pickers | ~5 KB | app-wide (layout chrome) |
templateEditor (+ .snippets*), templatesCaption | ~10 KB | /e/tmp/* only |
attribute, activitydetails | ~8 KB | activity view/edit routes |
editorg, organizationDetails | ~7 KB | /e/org/* only |
zod, ouForm, filePicker | ~6 KB | form/edit routes |
gforms | ~2.7 KB | /gforms only |
turnstile | ~2.5 KB | document export pages |
Watch out for shared components (non-"use client" files imported by
client components execute client-side too) — when in doubt whether a
namespace is client-reachable, keep it in the pick and remove it later.
Deliverable: two lists — APP_WIDE_NAMESPACES and a per-route-group
mapping.
Step 2 — Create a shared pick helper
New file src/lib/i18n/clientMessages.ts:
import type { Messages } from "next-intl";
export const pickMessages = (
messages: Messages,
namespaces: readonly string[],
): Partial<Messages> =>
Object.fromEntries(
Object.entries(messages).filter(([key]) => namespaces.includes(key)),
) as Partial<Messages>;
export const APP_WIDE_NAMESPACES = [
"common",
"MainNavBar",
"DrawerList",
"Footer",
"incidents",
"newTenantModal",
// …complete from Step 1
] as const;
(No lodash needed — a 5-line local helper avoids a new dependency.)
Step 3 — Restrict the app-wide provider
File: src/app/[locale]/layout.tsx
const messages = await getMessages();
<NextIntlClientProvider
locale={locale}
messages={pickMessages(messages, APP_WIDE_NAMESPACES)}
>
Explicit prop required — see the inheritance gotcha above.
Step 4 — Add nested providers for route-scoped namespaces
For each route group from Step 1, add a nested provider in its layout or page (Server Component), e.g.:
// src/app/[locale]/(app)/e/tmp/layout.tsx (new)
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { pickMessages, APP_WIDE_NAMESPACES } from "@/lib/i18n/clientMessages";
const TmpLayout = async ({ children }: { children: React.ReactNode }) => {
const messages = await getMessages();
return (
<NextIntlClientProvider
messages={pickMessages(messages, [
...APP_WIDE_NAMESPACES, // messages prop is atomic — re-include
"templateEditor",
"templateEditor.snippets",
"templateEditor.snippets.categories",
"templatesCaption",
])}
>
{children}
</NextIntlClientProvider>
);
};
export default TmpLayout;
Repeat for: /e/org/*, activity edit routes, /gforms, export pages.
Step 5 — Handle the outliers
zod— if only used by form validation running client-side, it belongs to the edit-route providers; if server actions translate errors server-side, it may leave the client entirely.- Any namespace used by exactly one client component: consider option 1 from the next-intl docs instead — translate in the server parent, pass the finished string as a prop, delete the namespace from every pick.
Step 6 — Verify
npm run build— no missing-message errors.- Runtime check with
onError: in development, next-intl logsMISSING_MESSAGEto the console — click through every route (dashboard, view, edit, org, templates, gforms, export, event pages) in two locales. - Measure:
curl -s https://demo.<domain>/en | wc -cbefore/after — expect roughly 40–50 KB less HTML on the dashboard; repeat for a template editor page (should still include its namespaces). - Run the existing test suites — component tests that render providers with full catalogs are unaffected, but any test asserting on provider props may need updating.
Rollback / safety
Each step is independently revertible; Step 3 is the only behavior-risk
change (a forgotten namespace shows raw keys / logs MISSING_MESSAGE
rather than crashing). If a missed namespace surfaces in production,
hotfix = add it to APP_WIDE_NAMESPACES.
Future follow-up (optional, not required for the win)
Adopt a client.* namespace convention (or next-intl's experimental
useExtracted with namespaces) so the app-wide pick list becomes
self-maintaining instead of a curated array.