Before we launched into a new country rollout, I took a hard look at our i18n workflow. Nobody was complaining loudly. But every time a translation task came up, you could feel the quiet dread in the team.
The bottleneck wasn't technical. It was a responsibility boundary that had been drawn in the wrong place.
The Workflow Nobody Questioned
Our UX designer used a Figma plugin to export translations as a JSON attachment, which she uploaded to the corresponding ticket. Dev would download it, do a local replace, then manually wire up strings in the codebase by matching layer names and context fields.
The exported JSON looked like this:
{
"items": [
{
"nodeId": "12:345",
"layerName": "Button Label",
"context": "Homepage → Hero → CTA",
"figmaFile": "Translation_EN_US_v3",
"lastUpdated": "2024-01-15",
"value": "Get Started"
}
]
}
The codebase only needed { "button.label": "Get Started" }. Everything else was noise that came along for the ride.
Seems manageable. Until you look closer.
Five Cracks in the Foundation
1. UX was the only exit point — but not the consumer
Our UX designer was shared across multiple teams. She was maintaining several different versions of the translation JSON locally, one per team. Dev couldn't move without her input. She had become the data's sole gatekeeper, despite not being the one who needed it.
2. Layer names weren't unique — because why would they be?
Layer names are design conventions, not engineering constraints. Nothing stopped a second designer from using "Title" (capital T) while another used "title", or reusing the same layer name for two different strings across different parts of the design. Same key, different values. Different keys, same values. These collisions existed silently in the codebase.
// What ended up in the locale file after two designers touched the same file:
{
"title": "Welcome to our store",
"Title": "Store"
}
// Two keys. One was a mistake. Neither was caught before merging.
3. Placeholders created a translation dead zone
Strings with dynamic content — like "Hi, {firstName}! You have {count} items." — required a workaround. Dev would submit a version with placeholders, hand it to UX for translation, and hope the placeholders survived the round trip. They didn't always.
// Dev's version
{ "greeting": "Hi, {firstName}! You have {count} items in your cart." }
// Returned from UX after Spanish translation
{ "greeting": "Hola, {nombre}! Tienes {count} artículos." }
// {firstName} → {nombre}. Semantically fine. Breaks at runtime.
4. The JSON brought too much baggage into the codebase
The plugin exported what it knew: node IDs, context fields, Figma file names. None of it was useful in production. But it lived in the codebase anyway, because there was no filtering step.
5. Scaling to more countries meant scaling the pain
There was no structure. Adding a country meant adding more JSON files, more versions, more formats to reconcile — because different teams had different expectations of what the file should look like.
translations/
en_us_team_a_v2.json
en_us_team_b_final.json ← which one is current?
es_mx_team_a_v1.json
es_mx_team_b_draft.json ← different structure, same team
The Real Problem
Looking at all five issues together, I kept coming back to one thing:
The responsibility boundary was wrong.
UX had been pressed into being the data's export mechanism — not because it made sense, but because she was the one with the Figma access and the plugin. She wasn't the consumer of this data. Dev was. And dev was blocked, waiting for an output from someone who was already stretched thin across multiple teams.
The more important realization: Figma itself was already the source of truth. UX maintained the designs there. She communicated with translators there. All the content existed there already. The JSON attachment in the middle was an unnecessary intermediary.
If dev could read Figma directly, the attachment step didn't need to exist.
The Solution: Four Moving Parts
The fix wasn't just a script. It was a redesign of four things simultaneously:
| Layer | What changed |
|---|---|
| Convention |
<t> prefix in layer names to mark translatable strings |
| Export | Figma REST API script replaces plugin + manual attachment |
| Validation | Automated consistency checks before anything hits the codebase |
| Tooling | Overwrite JSON, unused key cleanup, coverage tests |
Missing any one of these would have made the solution incomplete.
The Key Design Decision: <t> Prefix
The most important design choice wasn't in the code — it was a naming convention.
Any text layer that needed translation would have its layer name prefixed with <t>. The script would filter on that prefix, strip it during export, and produce clean { key: value } output.
I considered alternatives:
- Node IDs: Unstable. Moving or duplicating a layer changes the ID. Also completely opaque to UX — she can't verify her own work.
- A separate JSON mapping file: Introduces a new sync problem. Who maintains it? Which version is current? Same problem, different form.
The prefix convention cost UX almost nothing (add a tag, everything else stays the same), gave dev a clear signal, and kept the Figma file human-readable. Country frames acted as natural namespaces — UX already used frames to organize different countries, so no extra structure was needed.
Country-specific strings got an extended prefix: <t>en_us.some_key. The script recognized this pattern and excluded those keys from cross-country validation.
Reading from Figma
The script calls the Figma REST API, walks the node tree, and filters for <t>-prefixed text layers:
// Environment: Node.js
// Reads Figma file and extracts translation layers by prefix
async function fetchTranslationNodes(fileKey: string, token: string) {
const res = await fetch(`https://api.figma.com/v1/files/${fileKey}`, {
headers: { 'X-Figma-Token': token },
});
const data = await res.json();
return data.document;
}
function extractTranslationLayers(node: FigmaNode): TranslationEntry[] {
const results: TranslationEntry[] = [];
if (node.type === 'TEXT' && node.name.startsWith('<t>')) {
const key = node.name.replace(/^<t>/, '').trim();
results.push({ key, value: node.characters });
}
if (node.children) {
node.children.forEach(child => {
results.push(...extractTranslationLayers(child));
});
}
return results;
}
Output is clean: { "key": "value" }. No node IDs. No context fields. No noise.
The Validation Layer
This is what made the solution trustworthy rather than just functional.
Before writing any output, the script checks:
// Validates consistency across countries, generates an error report
function validateTranslations(
countries: Record<string, Record<string, string>>
): ValidationError[] {
const errors: ValidationError[] = [];
const allKeys = new Set(Object.values(countries).flatMap(Object.keys));
allKeys.forEach(key => {
// Same key, different values across countries
const values = Object.entries(countries)
.filter(([, t]) => key in t)
.map(([country, t]) => ({ country, value: t[key] }));
if (new Set(values.map(v => v.value)).size > 1) {
errors.push({ type: 'KEY_VALUE_MISMATCH', key, values });
}
// Key exists in some countries but not others
const missingIn = Object.keys(countries).filter(c => !(key in countries[c]));
if (missingIn.length > 0 && missingIn.length < Object.keys(countries).length) {
errors.push({ type: 'MISSING_IN_COUNTRY', key, missingIn });
}
});
return errors;
}
If validation fails, the script outputs an error report — grouped by country, with the specific Figma layer names called out so UX knows exactly where to fix things. Problems get resolved in Figma, not in the codebase.
Handling Placeholders and Dynamic Content
For strings with dynamic content or special styling, we didn't try to push that complexity onto UX. Instead, dev maintains a local overwrite file that runs after export — matching keys and replacing values:
// UX owns the base text, dev owns the format layer
// These don't need to know about each other
function applyOverrides(
base: Record<string, string>,
overrides: Record<string, string>
): Record<string, string> {
return { ...base, ...overrides };
}
UX writes "Hi, username!". Dev's overwrite turns it into "Hi, {firstName}!". Clean separation — neither has to understand the other's domain.
How It Spread (Without Us Pushing It)
This came up as a topic in our cross-team FE Tech Guild. Our team ran it as a pilot. I assumed that if it worked well, we'd need to write docs, organize onboarding sessions, build a proper rollout plan.
We didn't have to.
Because UX was shared across teams, the workflow traveled with her. Once this process genuinely reduced her workload — from maintaining multiple JSON files in different formats for different teams, to maintaining one Figma file — she brought it to the next team herself.
The tool spread through the person who had been most burdened by the old process.
That's a useful signal: if you want something to be adopted, find the person actually carrying the weight, and solve for them. Dev found the workflow annoying. UX was the one managing five versions of a JSON file across four teams. Different pain levels.
What Was Actually Hard
Not the Figma API. That part took a couple of hours with the docs.
The harder parts:
Designing a convention UX would accept. The <t> prefix couldn't feel like extra work — it had to feel like a trade (add a tag, never export a JSON file again). That framing mattered.
Making the error report actionable. "Key X has a problem" doesn't help. "In the es_MX Frame, the layer named <t>checkout.submit has value 'Enviar', but in en_US the same key has value 'Place Order' — please confirm which is correct" does.
Understanding who was really carrying the cost. If we'd just replaced "UX exports" with "dev manually calls the API," we'd have moved the friction without reducing it. The actual change was redistributing ownership: UX back to designing, dev owning the extraction pipeline.
This solution worked for our team's scale and workflow. If your UX layer naming is highly inconsistent, or you need CI integration for high-frequency translation updates, it would need adjustment. But the underlying question is worth asking regardless of tooling: who is your pipeline currently asking to do something that isn't really their job?
References
- Figma REST API — file structure and authentication
- Figma TextNode (Plugin API) — field reference, similar structure to REST API responses
I'm yuki — a design engineer with an architecture background, working at the intersection of frontend and AI. I write about building things that sit between design and engineering.
More projects and writing → yukiuix.com
Top comments (0)