Summary
There is no ergonomic way to translate plain string values produced by pure, shared helper functions that run in both Server and Client Components (Next.js App Router / RSC). The current string-translation primitives are each tied to one execution context, and a closely-related extractor inconsistency (getTranslation's t() is not picked up by the CLI) makes the one server-side path awkward. This pushes real apps toward leaving such strings untranslated.
Filing with a full real-world breakdown so it can drive an API/extractor improvement.
Context
- Next.js 16 App Router, mixed Server + Client Components.
tyndale + tyndale-react + tyndale-next, defaultLocale: "en", locales: ["pt-BR"] (English authored → pt-BR generated). Catalog committed at public/_tyndale/.
- We internationalized the whole app with
<T> / msg() / useTranslation() successfully. The only category we could not cleanly cover: pure label/format helper functions returning string, imported by both server and client components, sometimes composed into larger strings.
The concrete blocker
A typical shared helper module (used by a client ReportExportPanel and several server report components):
// reporting-filter-helpers.ts (plain module — no "use client", no hooks)
export function statusLabel(status: DashboardStatus): string {
const labels: Record<DashboardStatus, string> = {
valid: "Em conformidade", expired: "Vencido", /* … */
};
return labels[status];
}
// …and it gets COMPOSED into a larger string:
export function activeFilterLabels(filters: ReportSelectedFilters): string[] {
const labels: string[] = [];
if (filters.status) labels.push(`Status: ${statusLabel(filters.status)}`); // ← interpolated into a template
// …
return labels;
}
These functions:
- must return a
string (they feed aria-labels, string composition, and Record lookups),
- are imported by both server and client components,
- are pure (no React context, not
async).
Why each current primitive can't cover it
| Primitive |
Returns |
Works in |
Blocker for this case |
<T> |
ReactElement |
server + client |
JSX children only — not a string value |
msg() |
ReactElement |
server + client |
Not a string; can't go in aria-label, template literals, or Record<…, string> |
msgString() |
string (source, unchanged) |
anywhere |
Marks for extraction but does not translate — returns the source verbatim |
useTranslation() → t() |
string |
client only (hook) |
Can't be called in a pure function or a Server Component |
getTranslation() → t() |
string |
server only, async |
Can't be called in a pure sync function; forces callers async; and its t('literal') calls are NOT extracted by the CLI (see below) |
So: the only thing that both returns a string and translates is t(), and there is no single t that a pure, context-agnostic, synchronous helper can obtain. You must thread a translator down from every call site — and it's a different translator depending on whether the caller is a client component (useTranslation) or a server component (await getTranslation). For a shared module that's both, there's no clean answer.
Secondary bug: getTranslation's t() is not extracted
Discovered while internationalizing a Server Component's metadata (generateMetadata):
const t = await getTranslation({ locale, defaultLocale, outputPath });
return { description: t("Multi-tenant NR training and regulatory compliance platform.") };
bunx tyndale extract reports 0 new — the string never enters the catalog, so at runtime t() falls back to the source (English shown to pt-BR users). By contrast, useTranslation()'s t('literal') is extracted (verified: a t("Select language") in a client component appeared in the catalog and translated correctly).
Workaround that works, but is non-obvious boilerplate:
import { msgString } from "tyndale-react";
return { description: t(msgString("Multi-tenant NR training and regulatory compliance platform.")) };
// msgString() is extracted → string lands in the catalog; t() then resolves it by hash at runtime.
The asymmetry (useTranslation().t extracted, getTranslation().t not) is surprising and, I suspect, unintended.
Tertiary: composed strings can't be translated
Even with a working t, activeFilterLabels builds `Status: ${statusLabel(x)}` — the composed runtime string is never a catalog entry, so it can't be translated as a unit. The idiomatic fix is to return structured data ({ key, value }) and translate the label at the render site, which is a non-trivial refactor of every such helper + caller.
Repro (minimal)
defaultLocale: "en", locales: ["pt-BR"].
- Server Component:
const t = await getTranslation({locale, defaultLocale, outputPath}); return <p>{t("Hello world")}</p>;
npx tyndale extract → 0 new ("Hello world" absent from en.json).
- Same string via
useTranslation().t("Hello world") in a client component → extracted fine.
Proposed directions (pick any)
- Context-agnostic sync
t. A single importable translator that resolves the active locale on the server via AsyncLocalStorage/RSC request context (no await, no hook) and via React context on the client — so pure shared helpers can do import { t } from "tyndale-react" (or receive one uniform t) regardless of where they run.
- Extract
getTranslation().t() like useTranslation().t(). Make the CLI recognize the t returned by getTranslation(...) so msgString() wrapping isn't required. (Or document t(msgString(...)) as the intended server pattern — but parity would be less surprising.)
- First-class guidance for label maps / composed strings. A documented pattern (or helper) for
Record<Key, message> and for "label + value" composition that stays translatable, instead of string interpolation.
What we did meanwhile
Internationalized everything else (<T>, msg(), useTranslation(), getTranslation()+msgString() for metadata). The shared report label helpers were left as pt-BR source — they render correctly for pt-BR users today — pending an ergonomic server+client string API.
Happy to provide more code or test a prototype.
Summary
There is no ergonomic way to translate plain string values produced by pure, shared helper functions that run in both Server and Client Components (Next.js App Router / RSC). The current string-translation primitives are each tied to one execution context, and a closely-related extractor inconsistency (
getTranslation'st()is not picked up by the CLI) makes the one server-side path awkward. This pushes real apps toward leaving such strings untranslated.Filing with a full real-world breakdown so it can drive an API/extractor improvement.
Context
tyndale+tyndale-react+tyndale-next,defaultLocale: "en",locales: ["pt-BR"](English authored → pt-BR generated). Catalog committed atpublic/_tyndale/.<T>/msg()/useTranslation()successfully. The only category we could not cleanly cover: pure label/format helper functions returningstring, imported by both server and client components, sometimes composed into larger strings.The concrete blocker
A typical shared helper module (used by a client
ReportExportPaneland several server report components):These functions:
string(they feedaria-labels, string composition, andRecordlookups),async).Why each current primitive can't cover it
<T>stringvaluemsg()string; can't go inaria-label, template literals, orRecord<…, string>msgString()string(source, unchanged)useTranslation()→t()stringgetTranslation()→t()stringasyncasync; and itst('literal')calls are NOT extracted by the CLI (see below)So: the only thing that both returns a string and translates is
t(), and there is no singletthat a pure, context-agnostic, synchronous helper can obtain. You must thread a translator down from every call site — and it's a different translator depending on whether the caller is a client component (useTranslation) or a server component (await getTranslation). For a shared module that's both, there's no clean answer.Secondary bug:
getTranslation'st()is not extractedDiscovered while internationalizing a Server Component's metadata (
generateMetadata):bunx tyndale extractreports 0 new — the string never enters the catalog, so at runtimet()falls back to the source (English shown to pt-BR users). By contrast,useTranslation()'st('literal')is extracted (verified: at("Select language")in a client component appeared in the catalog and translated correctly).Workaround that works, but is non-obvious boilerplate:
The asymmetry (
useTranslation().textracted,getTranslation().tnot) is surprising and, I suspect, unintended.Tertiary: composed strings can't be translated
Even with a working
t,activeFilterLabelsbuilds`Status: ${statusLabel(x)}`— the composed runtime string is never a catalog entry, so it can't be translated as a unit. The idiomatic fix is to return structured data ({ key, value }) and translate the label at the render site, which is a non-trivial refactor of every such helper + caller.Repro (minimal)
defaultLocale: "en",locales: ["pt-BR"].const t = await getTranslation({locale, defaultLocale, outputPath}); return <p>{t("Hello world")}</p>;npx tyndale extract→ 0 new ("Hello world" absent fromen.json).useTranslation().t("Hello world")in a client component → extracted fine.Proposed directions (pick any)
t. A single importable translator that resolves the active locale on the server viaAsyncLocalStorage/RSC request context (noawait, no hook) and via React context on the client — so pure shared helpers can doimport { t } from "tyndale-react"(or receive one uniformt) regardless of where they run.getTranslation().t()likeuseTranslation().t(). Make the CLI recognize thetreturned bygetTranslation(...)somsgString()wrapping isn't required. (Or documentt(msgString(...))as the intended server pattern — but parity would be less surprising.)Record<Key, message>and for "label + value" composition that stays translatable, instead ofstringinterpolation.What we did meanwhile
Internationalized everything else (
<T>,msg(),useTranslation(),getTranslation()+msgString()for metadata). The shared report label helpers were left as pt-BR source — they render correctly for pt-BR users today — pending an ergonomic server+client string API.Happy to provide more code or test a prototype.