Shipeasy
Translations

Label keys

How Shipeasy Translations names, hashes, chunks, describes, and deletes the strings your code references.

ReferenceOn this page · 9 min readUpdated · May 3, 2026Works with · Next, Vite, Remix, Astro, Expo

A label key is a stable identifier for a translatable string. It is what your code references; the value (the actual translation) varies per profile. Keys are the contract between your codebase and every locale you ship.

Shipeasy Translations's job is to make keys feel invisible: you write English in code, the codemod hashes it, the AI translates it, and the runtime resolves it back. But understanding what a key actually is — and how it survives renames, refactors, and re-scans — is what stops your localization from drifting six months in.

Shorthand

In every example on this page, t(...) is shorthand for i18n.t(...) from @shipeasy/sdk/client (or /server for SSR). The codemod always emits the full i18n.t(...) form with the import added.

Schema

Field
Type
Description
keyrequired
string
Stable identifier. Either explicit (checkout.cta_label) or auto-derived from the source value's hash plus the file path namespace.
valuerequired
string
The string in this profile. Plain text; placeholders use {{name}} ICU MessageFormat syntax.
hashrequired
string
SHA-256 of the source value, truncated to 12 chars. Used for change detection — when the English changes, the hash changes, target translations are marked stale.
chunk
string ?
Grouping for lazy loading by route or feature. One chunk per HTTP fetch.
namespace
string ?
Auto-derived from file path (e.g. src/checkout/Page.tsx → checkout). Used for sibling-key context in AI translation.
description
string ?
Highly recommended. Free-form notes for translators and the AI. Read on every Claude call.
max_length
number ?
Soft limit nudged into the AI prompt. Useful for buttons and table headers.
placeholders
string[] ?
List of allowed placeholder names. Validated on translation upload.
created_at
datetime ?
Auto.
last_seen_in_code
datetime ?
Updated by every code scan. Drives the Unused keys view.

Identity: explicit keys vs hashed keys

There are two ways a key gets its identifier, and you can mix both in one project:

The codemod defaults to hashed keys for raw JSX strings (<h1>Welcome back</h1>) and explicit keys for anything inside a clearly labelled CTA component. You can flip the default with --keys=explicit or --keys=hashed.

Naming conventions

Explicit keys are forever-ish — they are referenced from code, drafted, translated, reviewed, published. Renaming has a cost: every translator's context resets. Pick well the first time.

  • Namespace by surface: checkout.cta_label, checkout.summary_total.
  • Snake_case the leaf: cta_label, not ctaLabel or cta-label.
  • Describe role, not content: header.cta_primary ages better than header.start_free_trial (the copy will change; the role won't).
  • Don't embed the language: welcome_en is wrong; welcome is right.
  • Don't embed the locale into the key: one key is shared across every profile; each profile supplies its own translation. Don't duplicate a key per locale.

A loose convention that scales well: <surface>.<component>.<role>. So checkout.summary.cta_primary, settings.billing.heading, auth.signup.subtitle.

Discovery: codemod vs scan

The CLI discovers keys two ways:

Either way, the key set in your source profile should match the keys actually referenced in code. Use shipeasy i18n validate in CI to enforce that — it fails the build if a t("…") call has no matching key in the profile.

shipeasy i18n scan ./src --rewrite --profile en
shipeasy i18n validate --profile en ./src

The codemod skips strings it cannot safely rewrite — anything with embedded JSX, dynamic interpolation in a non-template position, or suspicious-looking content (URLs, emails, all-numeric). Skipped candidates land in i18n-codemod-review.json at the project root with file, line, and reason.

Placeholders and pluralization

The runtime supports ICU MessageFormat for both interpolation and plurals.

t("greeting", { name: "Vadim" });
// value: "Hello {{name}}!"
// → "Hello Vadim!"
t("notifications", { count: 3 });
// value: "{{count, plural, one {# notification} other {# notifications}}}"
// → "3 notifications"
t("invite_status", { gender: "f", name: "Anna" });
// value: "{{gender, select, m {He} f {She} other {They}}} invited {{name}}."
// → "She invited Anna."

Pluralization rules follow CLDR — Shipeasy Translations preserves the structure across translation. The AI is instructed to fill out every plural category required by the target locale (Russian needs one / few / many / other; Arabic needs zero / one / two / few / many / other). Categories the source locale does not use are filled by analogy.

Validate your plural categories

Always spot-check plural keys in low-resource locales after AI translation. Claude is good at this but not perfect — wrong forms slip through and can survive review if no one on the team reads the locale.

Rich text and HTML

For mixed text + markup (a sentence with a bold word, an inline link), wrap it as a fragment:

<T id="legal.tos_consent">
  By continuing you agree to our <Link href="/tos">Terms</Link> and{" "}
  <Link href="/privacy">Privacy Policy</Link>.
</T>

The codemod produces a value with placeholders for each component:

By continuing you agree to our {{0}}Terms{{/0}} and {{1}}Privacy Policy{{/1}}.

Translators see the placeholders as visible tokens; the runtime swaps them back into React elements. This keeps translation safe (no HTML injection) and re-renderable on the client without re-parsing.

Descriptions matter for the AI

When the AI translates a key, it sees:

  • The key (checkout.cta_label).
  • The source value (Pay).
  • The description, if you've added one (CTA on the checkout summary page; verb, must be short).
  • Up to 8 "context keys" — neighbouring keys from the same chunk or namespace.
  • Project-level tone and glossary.

A good description doubles translation quality on ambiguous strings. Add one for any key whose meaning is not obvious from its source value. Common cases that need a description:

  • Single words (Pay, Match, Order) — disambiguate verb vs noun.
  • Branded UI labels (Spaces, Workflows) — mark do-not-translate.
  • Length-constrained strings (Save, Done) — tell the AI the budget.
  • Idioms (You're all set!) — explain the intent.
shipeasy i18n keys describe checkout.cta_label \
  --description "CTA button on the checkout summary page; verb, must be ≤12 chars in target locales" \
  --max-length 12

You can also set descriptions inline in code with a comment that the codemod picks up:

{
  /* i18n: CTA button, verb, ≤12 chars */
}
<button>{t("checkout.cta_label", "Pay")}</button>;

Chunking for performance

By default every key in a profile is served in one bundle. For large apps (≥5k keys), split into chunks so the loader fetches only what each route needs.

const labels = await loadLabels({ profile: "en", chunk: "checkout" });

You define a chunk via:

  • The chunk field on each key (set when pushing).
  • Or by directory in the codemod config:
shipeasy.config.json
{
  "i18n": {
    "chunks": {
      "checkout": ["src/checkout/**"],
      "settings": ["src/settings/**"],
      "marketing": ["src/(marketing)/**"],
      "shared": ["src/components/**"]
    }
  }
}

The loader fetches chunks active for the current page; navigations fetch additional chunks lazily. This keeps the initial bundle tiny and warm.

KV storage layout per published version:

i18n/v1/{project}/{profile}/v{n}/index.json     — chunk → hash manifest, ~2 KB
i18n/v1/{project}/{profile}/v{n}/shared.json    — chunk bundle
i18n/v1/{project}/{profile}/v{n}/checkout.json
i18n/v1/{project}/{profile}/v{n}/settings.json
i18n/v1/{project}/{profile}/v{n}/marketing.json

The index is cached aggressively at the edge; chunk URLs are content-hashed in the index, so a single-key edit in checkout purges only checkout.json for the new version. Other chunks stay warm.

Don't chunk too early

Under ~5,000 keys, a single bundle is usually faster end-to-end (one request, smaller HTTP overhead). Chunk when the bundle exceeds ~80 KB gzipped or when route-level lazy loading actually pays off.

Stale keys and re-translation

Shipeasy Translations detects when a source value changes (the hash changes) and marks every target translation as stale. Stale keys still serve their old value at the loader — nothing breaks — but they show up in the dashboard with a yellow flag and are eligible for re-translation.

shipeasy i18n translate --profile fr --ai --stale-only

The AI run sees the previous source value, the new source value, and the previous translation, and proposes an updated value. Most edits are tiny ("Sign in" → "Sign in →") and the AI keeps the existing translation; meaningful edits get a new draft.

Deleting a key

Deletion is destructive — it removes the key from every profile, every active draft, every version going forward (old published versions still contain the key, since they are immutable).

shipeasy i18n keys delete checkout.old_cta_label --profile en

In practice you should:

  1. Remove the t("checkout.old_cta_label") call from code first.
  2. Run shipeasy i18n validate to confirm no references remain.
  3. Then delete from the dashboard or CLI.

The dashboard's Unused keys tab uses the last_seen_in_code timestamp from your latest validate run to surface candidates. By default keys not seen in code for 30 days appear there; you can sweep them with:

shipeasy i18n keys prune --not-seen-in 30d --dry-run

Drop --dry-run once you trust the list.

Soft-delete vs hard-delete

The CLI's delete hard-removes; the dashboard offers a Mark deprecated action that keeps the key in the source profile but hides it from translation drafts. Deprecated keys are excluded from coverage stats. Use this when you are mid-migration and old code still references the key.

NEXT

Translate them — safely.

Every translation lands as a draft, never directly in a profile. Review side-by-side, edit inline, then merge.

Validate keys
$shipeasy i18n validate --profile en ./src
Prune unused
$shipeasy i18n keys prune --not-seen-in 30d
Stable hashes · stale detection · safe deletion
Was this page helpful?✎ Edit on GitHub

On this page