Skip to main content

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_NAMESPACES list 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). useExtracted doesn't auto-split; precompile: true (already enabled) only speeds up formatting — neither changes which messages ship.
  • Inheritance gotcha: a NextIntlClientProvider rendered from a Server Component auto-inherits the full catalog from request config. Removing the messages prop changes nothing — you must pass an explicit subset (or null).
  • Nested providers inherit locale/timeZone/etc. from ancestors, but messages is atomic: an inner provider's messages replace, not merge — include everything its subtree needs.
  • 96 of 101 useTranslations call-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):

NamespaceSizeScope
common~4.5 KBapp-wide (61 call sites)
MainNavBar, DrawerList, Footer, incidents, newTenantModal, pickers~5 KBapp-wide (layout chrome)
templateEditor (+ .snippets*), templatesCaption~10 KB/e/tmp/* only
attribute, activitydetails~8 KBactivity view/edit routes
editorg, organizationDetails~7 KB/e/org/* only
zod, ouForm, filePicker~6 KBform/edit routes
gforms~2.7 KB/gforms only
turnstile~2.5 KBdocument 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

  1. npm run build — no missing-message errors.
  2. Runtime check with onError: in development, next-intl logs MISSING_MESSAGE to the console — click through every route (dashboard, view, edit, org, templates, gforms, export, event pages) in two locales.
  3. Measure: curl -s https://demo.<domain>/en | wc -c before/after — expect roughly 40–50 KB less HTML on the dashboard; repeat for a template editor page (should still include its namespaces).
  4. 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.