Skip to content

Translating strings in pure helpers shared by Server + Client Components has no ergonomic API (+ getTranslation().t() not extracted) #5

@ogrodev

Description

@ogrodev

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:

  1. must return a string (they feed aria-labels, string composition, and Record lookups),
  2. are imported by both server and client components,
  3. 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)

  1. defaultLocale: "en", locales: ["pt-BR"].
  2. Server Component: const t = await getTranslation({locale, defaultLocale, outputPath}); return <p>{t("Hello world")}</p>;
  3. npx tyndale extract0 new ("Hello world" absent from en.json).
  4. Same string via useTranslation().t("Hello world") in a client component → extracted fine.

Proposed directions (pick any)

  1. 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.
  2. 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.)
  3. 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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions