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:
-
Extraction, at upload.
upload-custom-pageparsesbody_sourceand collects every marker into a flatkey → default textmap, stored on the page astranslatable_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. -
Fan-out, at publish. During
publish→publish-confirm, the platform machine-translates the values oftranslatable_strings— plustitleanddescription— into every locale inmodules.custom_pages.locales, minus the page’s own source locale. Each target locale becomes a page variant grouped under yourcanonical_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. -
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 thedata-t-keyattribute 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>keyis required and must be a static string literal.<T key={expr}>is rejected.- The default comes from the
defaultattribute (static string literal only), or else from static inner text. Self-closing<T key="x" default="…" />works; self-closing withoutdefaultis 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
<Tinside 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<Tfollowed 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-keyelement inside anotherdata-t-keyelement 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
languagedeclares 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,
languageis ignored: the page keeps its original source language.
URLs, canonical_slug, and hreflang
canonical_slugdefaults toslugand 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 slugindexmaps 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 acanonical_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 inlinesrcis the default for every locale without an override — no text is extracted for media markers.media_variantsis{ "<key>": { "<locale>": "<absolute URL>" } }. Every key must match adata-t-mediamarker in the body, and every URL must be absolutehttp(s).- Media URLs are authored, never machine-translated — they reach every locale exactly as you wrote them.
- Use
upload-mediato host images; it returns Cloudinary URLs withf_auto,q_autoalready applied. - The
data-t-mediaattribute 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.
slugandcanonical_slug.media_variantsURLs.schema_org_type,og_image,speakable_selector,published_date,updated_date.mcp_resourcestays 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 publish → publish-confirm:
- the three string values, plus title and description, are machine-translated into
esandde; - the site serves
/subscriptions/(English),/es/subscriptions/, and/de/subscriptions/, hreflang-linked withx-defaulton the English URL; - the Spanish page shows the
hero-es.jpgoverride; German falls back to the inlinehero-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.