Case studies
Worked translation scenarios — from "first key" to "translating a whole site".
Translate one button→
The minimal happy path. t('cta.subscribe') and ship.
Translate an existing app→
Scan the repo, propose keys, draft + review, publish in batches.
Per-locale tone & glossary→
Use profiles to keep "checkout" → "compra" not "compra" depending on register.
Disambiguate context→
"Open" the verb vs "Open" the adjective. Why context matters and how to provide it.
Long-form content from a CMS→
When not to put it in i18n — and how to integrate the CMS instead.
Translate one button
The minimum viable translation. One key, one component, three new locales. End-to-end in five minutes. The point is to prove the pipeline works on something tiny before you touch any of your real copy.
import { t } from "@shipeasy/sdk/server";
<button>{t("cta.subscribe", { default: "Subscribe" })}</button>;shipeasy i18n push --keys cta.subscribe
shipeasy i18n translate --locales fr,de,jaTranslate an existing app
Your app already has thousands of hardcoded English strings across hundreds of files. Going
through them by hand to wrap each one in t() is a quarter of work nobody is going to volunteer
for. The scan + codemod flow does it in two commands.
# 1. Scan: walk the repo, find JSX/TSX text nodes and ARIA strings, propose keys
shipeasy i18n scan --root apps/ui/src
# Output:
# ✔ Found 412 translatable strings in 87 files.
# ✔ Proposed key names (semantic, derived from component path).
# ✔ Wrote .shipeasy/scan-report.json — review before applying.
# 2. Preview the codemod (does not modify files)
shipeasy i18n codemod preview --report .shipeasy/scan-report.json
# 3. Apply: wraps strings in t(), creates keys in the project
shipeasy i18n codemod apply --report .shipeasy/scan-report.jsonThe scanner identifies translatable strings using heuristics: JSX text children, aria-label,
title, placeholder, alt, and string literals passed to a small allow-listed set of toast /
modal helpers. It skips strings that look like identifiers, URLs, file paths, error codes, single
characters, or anything inside test files. False positives are flagged in the report; review
before applying.
Apply in batches. A 412-string diff in one commit is unreviewable; --filter "src/components/cart/**"
narrows the codemod to one feature so each PR is a few dozen strings the reviewer can sanity-check
in context.
Once everything is wrapped, run shipeasy i18n push to upload the keys, then shipeasy i18n translate --locales fr,de,ja,es to draft. Drafts go to the dashboard for human review before
publishing — see the translation review guide for how to invite native speakers.
Per-locale tone & glossary
The same word in English maps to different registers in other languages. "Sign in" can be casual ("entrar") or formal ("iniciar sesión") in Spanish, and that choice should be consistent across hundreds of buttons in your app — not re-decided by whichever translator happens to look at each key.
Profiles solve this. A profile is a per-locale style guide that gets passed to the translation draft model and that the dashboard surfaces to human reviewers. Each profile is a small piece of text:
shipeasy i18n profile create --locale es \
--name "Spanish (ES) — casual" \
--description "Use tú, not usted. Active voice. Avoid English loanwords ('email' → 'correo')."
shipeasy i18n profile create --locale es-MX \
--name "Spanish (MX) — neutral" \
--description "Use usted in transactional flows. Allow common English loanwords ('email', 'login')."Profiles inherit: es-MX falls back to the es profile for anything it doesn't override. The
draft model applies the profile; the dashboard shows the profile next to every string under
review so the human reviewer is reminded of the rules.
Glossary entries pin specific terms:
shipeasy i18n glossary add --locale fr \
--term "checkout" --translation "paiement" --note "Never 'checkout', never 'caisse'."
shipeasy i18n glossary add --locale fr \
--term "subscription" --translation "abonnement"Glossary terms are enforced — the draft model uses the pinned translation, and the dashboard flags any human translation that contradicts the glossary. That's how you keep "checkout" as "paiement" across 400 keys without re-explaining yourself to each reviewer.
Disambiguate context
English overloads short words. "Open" is a verb on a button (<button>Open</button>) and an
adjective on a status label (<Badge>Open</Badge>). They translate to different words in most
languages — German has Öffnen (the verb) and Offen (the adjective), and a translator looking
at the string "Open" in isolation has a 50/50 chance of picking wrong.
Two ways to disambiguate.
Option 1 — separate keys with context-bearing names. The blunt fix, and usually the right one:
<button>{t("dialog.open.action", { default: "Open" })}</button>
<Badge>{t("ticket.status.open", { default: "Open" })}</Badge>Same default string, different keys. Translators see the key path (ticket.status.open) and
infer "this is a status label." The two strings can diverge translation-by-translation without
either side breaking.
Option 2 — a context hint on a shared key. Useful when you genuinely want one key reused
across many places but each call site has slightly different meaning:
t("Open", { context: "verb, action on a dialog" });
t("Open", { context: "adjective, ticket status" });The context string is shown to the translator next to the source string but is not part of the key — so two calls with the same source string and different contexts resolve to one translation slot per (source, context) pair. Use sparingly; nine times out of ten Option 1 is cleaner because the key carries the intent and you don't need a parallel context vocabulary.
Either way: never rely on the source string alone to convey intent. Translators don't see the JSX surrounding the string. They see a list of (key, source, optional context). Make sure that list is enough to translate from.
Long-form CMS content
You have a 2000-word product description, a 30-page support center, or a marketing blog. None of this belongs in i18n. Shipeasy's translation module is built for UI strings — short, sentence- fragment-shaped content that ships with the code. Long-form content has different needs:
- Editorial workflow (draft → review → publish), not key/value review.
- Rich content (images, links, video embeds, tables).
- Lives next to other long-form content of the same shape, not next to button labels.
- Updated by writers, not engineers.
A 2000-word string crammed into a translation key is hard to translate well (no per-paragraph review), hard to update (one typo invalidates the whole translation), and hard to render (line breaks vs. paragraphs vs. lists become string-formatting hacks).
What to do instead:
- Use a real headless CMS (Sanity, Contentful, Strapi, Payload) for the long-form content. They have localisation as a first-class feature, with editorial workflow, rich text, and version history.
- Use Shipeasy for the chrome — the navigation, CTAs, error messages, form labels, footer. Those are the strings that don't belong in the CMS because they aren't content, they're product surface.
- Cross-link the two by key. The CMS holds
article.welcome.body(rich text per locale). Shipeasy holdsarticle.welcome.cta(the "Get started" button below it). The page reads both.
If you genuinely have a short blob — a one-paragraph marketing card, a single legal disclaimer — the boundary is fuzzy and you can use Shipeasy for it. The rule of thumb: if there's a non-engineer on your team who needs to edit it without a code review, it belongs in a CMS, not in i18n.