Skip to content

Translate custom pages with markers

What this is

How to make a custom page translate itself. Custom pages — uploaded with upload-custom-page, page type custom, template custom-page — ship your markup as authored, so the platform never guesses which text is human-readable copy. You declare it with translation markers: <T key="…"> elements in Astro/JSX bodies, data-t-key="…" attributes in plain-HTML bodies. Marked strings are machine-translated into every locale configured for the custom-pages module; everything else ships untouched. Author the page once, in one language, and publish once — the platform builds one URL per locale.

How it works

Translation happens in three phases:

  1. Extraction, at upload. upload-custom-page parses body_source and collects every marker into a flat key → default text map, stored on the page as translatable_strings. The body itself is stored as you wrote it. Extraction is strict: any malformed marker (invalid key, missing default, nested markers) rejects the whole upload with a numbered error list — nothing is skipped silently.

  2. Fan-out, at publish. During publishpublish-confirm, the platform machine-translates the values of translatable_strings — plus title and description — into every locale in modules.custom_pages.locales, minus the page’s own source locale. Each target locale becomes a page variant grouped under your canonical_slug. Translated titles and descriptions are hard-cropped to 60 and 160 characters if a translation runs long — so when the project has additional locales, aim for ≤50/≤140 in the source to leave the translations headroom. Adding a locale to the module later fans every live custom page out into the new locale as drafts, ready to publish. With no locales configured, publish succeeds with zero fan-out.

  3. Substitution, at build. Each locale’s page is rendered by splicing the translated string into each marker location. In Astro/JSX bodies the entire <T …>…</T> element is replaced by the translated text — the wrapper is stripped, so keep classes and attributes on a parent element. In HTML bodies the data-t-key attribute is removed and the element’s text is replaced — the element itself survives, so put the attribute directly on the styled element. A key missing from a variant’s map falls back to the source default; pages never render with holes.

Parser selection is automatic — and it is a plain text scan, not parse-aware: a body that starts with --- frontmatter, or that contains the character sequence <T followed by whitespace, /, or > anywhere (even inside a JavaScript string or an HTML comment), is parsed as Astro/JSX; anything else is parsed as HTML and must use data-t-key. In Astro/JSX bodies, every byte outside marker locations ships exactly as authored (a body with zero markers passes through untouched). Plain-HTML bodies are re-serialized through a standards-compliant HTML parser during substitution: the meaning of your markup is preserved, but formatting details such as attribute quoting can normalize.

The cardinal rule: never hand-translate a custom page, and never create per-locale copies. One source page with markers is the entire workflow. Hand-made copies (landing plus a hand-translated landing-es) produce two unrelated pages: no shared canonical_slug, no hreflang links, no language switcher, and a second page that silently drifts every time you edit the first.

When to use it

  • You are shipping a landing, product, or marketing page on a project with more than one locale and want every locale generated at publish.
  • You are converting a Lovable, v0, or Bolt export and need to know which strings to wrap before uploading.
  • You are adding a locale to a project and want existing custom pages to appear in it.
  • A localized page is showing source-language sentences and you need to find the unmarked strings.
  • A page needs a different image per language (localized screenshots, posters with text in them).

Marker reference

Astro/JSX form: <T>

Two equivalent ways to declare the default (source-language) text:

<h1><T key="hero.title" default="Your storefront, in every language" /></h1>
<p><T key="hero.subtitle">One upload. Every locale. No hand-translation.</T></p>
  • key is required and must be a static string literal. <T key={expr}> is rejected.
  • The default comes from the default attribute (static string literal only), or else from static inner text. Self-closing <T key="x" default="…" /> works; self-closing without default is rejected for having no default.
  • Inner content must be plain text: expressions ({…}), nested elements, and fragments inside a marker are rejected.
  • <T> inside another <T> is rejected.
  • On the JSX path, a <T inside a JavaScript string literal is parsed as just a string — it is never extracted as a marker. But parser selection happens before parsing, as a plain text scan: the sequence <T followed by whitespace, /, or > anywhere in the body — including inside a script string — routes the whole body to the JSX parser. In an HTML body, keep that sequence out of scripts (split it as '<' + 'T ' if you truly need it), or the body flips to the JSX parser and fails to parse.

HTML form: data-t-key

<h1 data-t-key="hero.title">Your storefront, in every language</h1>
<a class="cta" href="/signup/" data-t-key="hero.cta">Start free</a>
  • The element’s text content is the default; it must be non-empty.
  • The marked element must wrap plain text only — nested elements inside it are rejected.
  • A data-t-key element inside another data-t-key element is rejected.
  • HTML comments cannot carry markers.

Key naming rules

Keys must match ^[a-z][a-z0-9_.]*[a-z0-9]$:

  • start with a lowercase letter, end with a lowercase letter or digit;
  • interior characters: lowercase letters, digits, underscores, dots;
  • minimum two characters — single-character keys are rejected.

Use dots and underscores to namespace: hero.title, pricing.tier_pro.cta. The same key may appear multiple times only with an identical default — repeated strings (a “Book a demo” button used three times) deduplicate into one entry and get one consistent translation. The same key with two different defaults is rejected.

Unmarked text ships verbatim

This is the number-one translation mistake. Substitution rewrites marker locations and nothing else: any visible copy you did not mark is carried into every locale exactly as written. No warning fires — the symptom is a Spanish page with English sentences in it. Before uploading, read the body once asking a single question per string: “should a French reader see this in French?” If yes, mark it.

Standalone values follow the same test: mark values that contain words — “Up to 60 minutes”, “Mon–Fri” — or they read as source-language text on every localized page. Leave pure numeric and unit values — “1.7 L”, “8:00–18:00” — unmarked: they ship verbatim, which is exactly right for universal notation. Numbers inside marked sentences are preserved by translation.

Localizing script-driven text

Markers cannot live inside <script> — neither parser extracts from script content. Keep every translatable string in a marked DOM element and let JavaScript control visibility instead of producing prose: render each message state as its own marked element and toggle hidden attributes, and have scripts inject bare numbers into unmarked slots (<span data-result></span>) sitting next to marked label text. Never build sentences in JS — concatenated fragments translate as fragments, and word order does not survive across languages.

Accessible names and attribute text

Attribute text — alt, aria-label, placeholder — cannot carry markers and ships untranslated to every locale, so prefer visible marked text for labels. Where an element genuinely needs an accessible name, use aria-labelledby pointing at a marked element, so the name translates with the rest of the copy; mark purely decorative art aria-hidden="true" so it needs no name at all. Keep unavoidable attribute text short and language-neutral.

Source language: the language argument

  • language declares the locale of the body copy and marker defaults. It is optional and defaults to the project’s default locale.
  • It must be one of the project’s configured locales.
  • On a re-upload of an existing page, language is ignored: the page keeps its original source language.

URLs, canonical_slug, and hreflang

  • canonical_slug defaults to slug and is the durable identity that groups all locale variants of a page. Set it once; it survives republishes and slug renames.
  • URL pattern: the source/default locale serves at /{canonical_slug}/; every other locale at /{locale}/{canonical_slug}/. The slug index maps to the locale root (/ and /{locale}/).
  • Never write a locale prefix into the slug — routing adds it. Every locale variant’s stored slug equals the canonical_slug.
  • hreflang alternates (including x-default, pointing at the default-locale URL) are generated as soon as two or more live locale variants share a canonical_slug. None of this needs configuration.

Localized media: data-t-media and media_variants

Text markers translate strings; media markers swap assets per locale:

<img src="https://res.cloudinary.com/demo/image/upload/f_auto,q_auto/tf/hero-en.jpg"
data-t-media="hero.image" width="1200" height="800"
alt="Dashboard with English labels" loading="lazy">
"media_variants": {
"hero.image": {
"es": "https://res.cloudinary.com/demo/image/upload/f_auto,q_auto/tf/hero-es.jpg"
}
}
  • data-t-media="<key>" on an <img>, <video>, or <source> element declares a localizable media slot. The inline src is the default for every locale without an override — no text is extracted for media markers.
  • media_variants is { "<key>": { "<locale>": "<absolute URL>" } }. Every key must match a data-t-media marker in the body, and every URL must be absolute http(s).
  • Media URLs are authored, never machine-translated — they reach every locale exactly as you wrote them.
  • Use upload-media to host images; it returns Cloudinary URLs with f_auto,q_auto already applied.
  • The data-t-media attribute is stripped from the rendered page.

What is never translated

  • The body markup itself — every locale variant carries the identical body; only marker values differ at render.
  • slug and canonical_slug.
  • media_variants URLs.
  • schema_org_type, og_image, speakable_selector, published_date, updated_date.
  • mcp_resource stays only on the source page; locale variants drop it.
  • The sidebar label is dropped on variants, so localized menus fall back to the translated title.

Translated: title, description (cropped to 60/160 with an ellipsis if needed), and the values of translatable_strings. If one machine translation needs a correction, patch that single string per locale in Edit Mode’s Translations tab — clearing a value falls back to the source default.

Example

A project with modules.custom_pages.locales = ["en", "es", "de"] and default locale en. The body:

<section class="hero">
<h1 data-t-key="hero.title">Coffee subscriptions, delivered weekly</h1>
<p data-t-key="hero.subtitle">Freshly roasted beans from small farms, at your door every Monday.</p>
<a class="cta" href="/signup/" data-t-key="hero.cta">Start your subscription</a>
<img src="https://res.cloudinary.com/demo/image/upload/f_auto,q_auto/tf/hero-en.jpg"
data-t-media="hero.image" width="1200" height="800"
alt="A bag of freshly roasted coffee beans" data-hero>
</section>

The upload:

{
"project_id": "8fK2hQbV3yTGr1Lw9XcD",
"project_name": "Brew & Co",
"slug": "subscriptions",
"title": "Weekly coffee subscriptions",
"description": "Freshly roasted specialty coffee delivered to your door every week. Pause or cancel anytime.",
"body_source": "<section class=\"hero\">…</section>",
"media_variants": {
"hero.image": {
"es": "https://res.cloudinary.com/demo/image/upload/f_auto,q_auto/tf/hero-es.jpg"
}
}
}

The response reports "markers_extracted_count": 3 (the three text keys) and a draft page. After publishpublish-confirm:

  • the three string values, plus title and description, are machine-translated into es and de;
  • the site serves /subscriptions/ (English), /es/subscriptions/, and /de/subscriptions/, hreflang-linked with x-default on the English URL;
  • the Spanish page shows the hero-es.jpg override; German falls back to the inline hero-en.jpg;
  • the body markup of all three is identical — only the marked strings and the media slot differ.

Common errors

Untranslated sentences on a localized page. Not an error message — the symptom of unmarked copy. The string ships verbatim in every locale. Wrap it in a marker, re-upload, republish.

Marker extraction failed (1 error(s)): … — the upload was rejected; each numbered line carries the offset, line, column, key, and reason. Fix every listed marker and re-upload. The most common reasons follow.

Invalid marker key 'a'. Keys must match /^[a-z][a-z0-9_.]*[a-z0-9]$/ (lowercase, digits, underscore, dot).

Keys need at least two characters, a lowercase first character, and an alphanumeric last character.

Duplicate marker key 'hero.cta' with conflicting default. Existing default: 'Book a demo'. New default: 'Get started'. Use one default per key.

Reuse a key only for identical text; otherwise give the second string its own key.

language 'fr' is not in project.locales [en, es]. Add it to modules.custom_pages.locales first, or omit `language` to use the default_locale ('en').

Configure the locale on the custom-pages module (PUT /modules/custom_pages with locales), then re-upload. Adding the locale also fans existing live custom pages out into it as drafts.

media_variants key 'hero.image' has no matching data-t-media marker in the page body. Add data-t-media="hero.image" to the <img|video|source> element, or remove the override.

Every media_variants key must match a data-t-media attribute in the body.

Hand-translated page copies. Symptom: /landing/ and a hand-made /landing-es/ both live, with no hreflang between them, no language switcher, and drift on every edit. Delete the copy, configure the locale on the module, and let publish generate the variant.

Last updated: