Translate and localise
This guide translates the user-interface strings of a fictional booking app, Tablefor, from English into several languages. UI translation is a useful worst case: short strings with little context, placeholders that must survive untouched, plural forms, and a product glossary that must be used consistently. Techniques that work here also work for help articles, emails and product descriptions.
You will need: an API key in SYNTIC_API_KEY and Node.js 18 or later.
What to send with each batch
A translator needs more than the source text. Give the model the same brief you would give a professional:
| Input | Why |
|---|---|
Target locale (pt-PT, not just “Portuguese”) | European and Brazilian Portuguese differ in vocabulary and form of address. |
| Tone and form of address | Formal or informal “you” changes almost every string in German, French or Portuguese. |
| Glossary | Product terms (Table, Party size, Waitlist) must be the same everywhere. |
| Notes per string | ”Book” as a verb on a button is not “book” as a noun. |
| Placeholder rules | {count} and <b> must come back exactly as they went in. |
The prompt
// prompt.js
export function systemFor(locale, glossary) {
const terms = Object.entries(glossary[locale] ?? {}).map(([en, t]) => `- ${en} → ${t}`).join('\n')
return `
You translate the user interface of Tablefor, a restaurant booking app, from English into ${locale}.
Style: friendly and concise, informal form of address. Buttons are short imperative verbs.
Keep every placeholder exactly as written: {name}, {count}, %s, <b>...</b>, <link>...</link>.
Do not translate brand names: Tablefor, Apple Pay.
Use these terms:
${terms || '(no glossary for this locale)'}
You receive a JSON object of string keys to English text, with optional notes.
Reply with one JSON object mapping the same keys to translations, and nothing else.
`.trim()
}Translate a batch
Sending strings in batches of 30 to 60 gives the model context (it sees that “Book” sits next to “Choose a time”) and keeps the request count down.
// translate.js
import { systemFor } from './prompt.js'
const GLOSSARY = {
'pt-PT': { 'party size': 'número de pessoas', waitlist: 'lista de espera', table: 'mesa' },
'de-DE': { 'party size': 'Personenzahl', waitlist: 'Warteliste', table: 'Tisch' },
}
async function translateBatch(locale, strings) {
const res = await fetch('https://app.syntic.ai/v1/chat/completions', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.SYNTIC_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'Amara S1.2',
stream: false,
max_tokens: 4000,
messages: [
{ role: 'system', content: systemFor(locale, GLOSSARY) },
{ role: 'user', content: JSON.stringify(strings, null, 1) },
],
}),
})
const data = await res.json()
if (!res.ok) throw new Error(`${res.status} ${data.error}`)
if (data.choices[0].finish_reason === 'length') throw new Error('reply cut off; send a smaller batch')
const text = data.choices[0].message.content
return JSON.parse(text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1))
}
const source = {
'booking.cta': { text: 'Book', note: 'button that confirms a reservation' },
'booking.party': { text: 'Party size' },
'booking.confirmed': { text: 'You booked a table for {count} at <b>{restaurant}</b>.' },
'waitlist.joined': { text: "You're on the waitlist. We'll text you when a table is free." },
}
console.log(await translateBatch('pt-PT', source))Check every string automatically
Never ship model output to the UI without checks. These catch nearly all of the mistakes that break an app:
const TOKEN = /\{[a-z_]+\}|%[sd]|<\/?[a-z]+>/g
export function checkTranslation(key, en, out, locale) {
const problems = []
if (typeof out !== 'string' || !out.trim()) problems.push('missing')
else {
const want = (en.match(TOKEN) ?? []).sort().join(' ')
const got = (out.match(TOKEN) ?? []).sort().join(' ')
if (want !== got) problems.push(`placeholders differ: expected [${want}] got [${got}]`)
if (out.length > Math.max(20, en.length * 2.2)) problems.push('much longer than source; may not fit')
if (out === en && /[a-z]{4}/i.test(en)) problems.push('left untranslated')
}
return problems.map((p) => `${locale} ${key}: ${p}`)
}Send failing strings back in a second, smaller request that lists the problem next to each string. Strings that fail twice go to a human translator.
Plurals
Languages have different plural rules: English has two forms, Polish has three or four, Arabic six. Do not ask the model to invent plural logic inside one string. Use your i18n library’s plural format (ICU {count, plural, one {...} other {...}}) in the source and tell the model to fill in the categories the target language needs:
For ICU plural messages, return the plural categories required by the target locale
(for example one, few, many, other for Polish) and keep {count} in each.Then validate the result with the same library that will render it (for example intl-messageformat), which rejects missing categories.
Review quality
Automated checks catch broken strings, not awkward ones. Two lightweight reviews help:
- Back-translation sample: translate 20 random strings back into English with a separate request and read them next to the source. Large meaning shifts show up quickly.
- Native review of key screens: a fluent speaker reviews checkout, errors and onboarding, where wording matters most. Add every correction to the glossary or the notes so it sticks.
Longer content
For help articles and emails, translate whole sections rather than sentence by sentence, keep Markdown or HTML structure intact (say so in the prompt), and use a glossary as above. Very long documents fit in one request; the context window is 262,144 tokens. Split them anyway at section boundaries so that each reply stays within your max_tokens and a single failure does not redo the whole document.
Detecting language and replying in kind
For chat products, you usually want replies in the user’s language without asking. A single line in the system message does it: “Reply in the language of the user’s latest message.” If you need the language code for routing or analytics, ask for it in a tiny separate request with Amara Quick and max_tokens: 5.
Cost
A batch of 50 UI strings is roughly 1,500 input and 1,500 output tokens. Translating a 2,000-string app into ten locales is about 400 requests; at the rates on Pricing that is a few dollars of usage credits, and nothing extra inside a plan allowance. Cache-friendly prompts help here: keep the system message identical across batches of one locale.
Related pages
- Extract data to JSON: robust JSON parsing and validation.
- Prompting: format the output
- Prompting: use examples