Skip to content

Translations and formatting

Keep one template and render it in every language your customers read. The labels live in translation files, the request names the language, and numbers and dates are formatted to match.

de-CH.json
{ "invoice": "Rechnung", "dueDate": "Fällig am" }
<h1>{{localize "invoice"}} {{invoice.number}}</h1>
<p>{{localize "dueDate"}}: {{invoice.dueDate}}</p>

A request with "locale": "de-CH" prints Rechnung and Fällig am; one with "locale": "en-GB" reads en-GB.json instead.

  1. In Studio’s explorer, open the menu of the template’s folder and choose New entity → Localization. Name the file for its language tag, such as de-CH.json or en-GB.json. It can also go in a localizations folder inside the template’s folder.
  2. Write the labels as one JSON object, with the same keys in every file.
  3. Print a label with {{localize "key"}}, or read it from localization in the data, such as {{localization.invoice}}.
  4. Pick the language in the Preview box of the preview toolbar. The box appears once the template has a translation file.
  5. Publish the template. Production renders use the files from the published version on.

What to know about labels:

  • localize looks up top-level keys. A key the file does not have prints the key itself.
  • localize also works inside helpers.js functions.
  • The chosen file is added to the data as localization, unless your data already has a localization property; then yours wins.
  • When a translation file applies, the render data must be a JSON object.

The template’s own language is the locale in its page.json, shown in Studio as Language under PDF properties in the preview toolbar. A render uses it unless something picks another:

  • Reports API: send locale with the request. Without it, the language is en-US.

    {
    "format": "pdf",
    "template": { "name": "Invoice" },
    "data": { "invoice": { "number": "INV-2041" } },
    "locale": "de-CH"
    }

    See Render a report by name for runnable code in each client.

  • Renders API: send locale with a template version render. Without it, the render uses the page.json locale. A script can still change it, as below.

  • A script: set req.options.localization.language in a beforeRender script, for example from your data:

    scripts/language.js
    function beforeRender(req, res) {
    req.options.localization.language = req.data.customer.language ?? "en-GB";
    }

    A script’s choice wins over the request’s locale. See Handlebars templates for how scripts run.

A locale or script language must be a language tag such as en or de-CH; anything else fails with 400 BAD_REQUEST. The render’s language also becomes the PDF’s document language.

The render reads the first file that exists:

  1. The file for the render’s language.
  2. The file for the locale in page.json.
  3. en-GB.json.

File names must match the whole language tag, ignoring case. A de request does not find de-CH.json, and de-CH does not fall back to de.json. So a Reports API request without locale reads en-US.json if the template has one, and otherwise the page.json language’s file.

In an HTML template, write a helper in the template’s helpers.js tab. Number.prototype.toLocaleString() does not group digits and there is no Intl object, so build the text yourself. Keeping the separators in the translation files lets one helper serve every language:

de-CH.json
{ "groupSeparator": "’", "decimalSeparator": "." }
helpers.js
function formatMoney(amount, currency) {
const [whole, cents] = Number(amount).toFixed(2).split(".");
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, localize("groupSeparator"));
return `${currency} ${grouped}${localize("decimalSeparator")}${cents}`;
}
<td>{{formatMoney total "CHF"}}</td>

With de-CH and a total of 1234567.5, this prints CHF 1’234’567.50.

Helpers and scripts have two ways to print dates: require("moment") and the toLocale… methods of Date.

require("moment") returns a built-in subset of Moment. Keep the pattern and Moment’s language in the translation files too:

de-CH.json
{ "language": "de-CH", "dateFormat": "D. MMMM YYYY" }
helpers.js
const moment = require("moment");
function formatDate(value) {
return moment(value)
.locale(String(localize("language")))
.format(String(localize("dateFormat")));
}

{{formatDate invoice.dueDate}} prints 30. November 2026 in de-CH and, with "dateFormat": "D MMMM YYYY" in en-GB.json, 30 November 2026 in en-GB.

Supported Details
moment(), moment(value) Now, or whatever new Date(value) accepts, such as ISO 8601 strings and epoch milliseconds.
.format(pattern) The tokens below. Text in [brackets] is literal. Without a pattern, ISO 8601 with offset.
.locale(name), .lang(name) Month and weekday names. moment.locale(name) sets the default.
.isSame(other, "day") Same calendar day. Without "day", the same instant.
.diff(other) Difference in milliseconds only.
moment.duration(ms) .days(), .hours(), .minutes(), .seconds(), .milliseconds().
.toDate(), .valueOf(), .isValid() As in Moment.

Format tokens: YYYYYY, YYYYY, YYYY, YY, MMMM, MMM, MM, M, Do, DD, D, dddd, ddd, dd, HH, H, hh, h, mm, m, ss, s, SSS, A, a, ZZ and Z.

  • An unsupported token, or a unit other than milliseconds for .diff and moment.duration, fails the render with an error naming it, instead of printing something wrong.
  • moment(text, format) ignores the format argument.
  • In languages such as Polish and Russian, a month name after a day number takes its declined form, as in Moment.

toLocaleDateString, toLocaleTimeString and toLocaleString take a language and the usual weekday, year, month, day, hour, minute, second and hour12 options:

helpers.js
function longDate(value) {
return new Date(value).toLocaleDateString("de-CH", {
day: "numeric",
month: "long",
year: "numeric",
});
}

Month and weekday names, and the date layouts of the toLocale… methods, exist for en, en-GB, sv, nb, da, fi, et, de, fr, es, it, nl, pl, pt, cs, lt, lv, ru and uk. A regional tag such as de-AT uses its language’s names, and other languages fall back to English.

Helpers and scripts show dates in the server’s time zone, and the timeZone option of the toLocale… methods is ignored. Convert times to the reader’s zone before you send them: a date-time without an offset, such as 2026-11-30T09:00:00, prints as written.

  • Component templates format numbers, currency, percentages and dates with a format on a value binding, including a timeZone for dates, without any code. Without a locale in the format, the render’s language is used. Translation files work there too.

  • Spreadsheet templates built from sheets and columns take a label per language for a title or header:

    {
    "header": { "default": "Amount", "de": "Betrag", "lt-LT": "Suma" },
    "value": "sum",
    "format": "decimal"
    }

    The render’s language picks the label: an exact match first, then the language without its region (de-CH finds de), then default. default is required. Up to 20 entries, each label up to 200 characters.

  • Workbook templates designed in Excel use helpers, localize and translation files in their cells like HTML templates.