---
title: "Translate custom pages with markers"
description: "Marker syntax and translation rules for custom pages: T keys, data-t-key, media variants, locale URLs, and what publish auto-translates."
url: "https://comstack.ai/docs/custom-pages/translation/"
---
## 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 `publish` → `publish-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:

```astro
<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`

```html
<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:

```html
<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">
```

```json
"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:

```html
<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:

```json
{
  "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 `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.

## Related

- [Custom pages overview](/docs/custom-pages.md)
- [Pass the custom page quality gates](/docs/custom-pages/quality.md)
- [Page types: knowledge vs custom](/docs/mcp/page-types.md)
- [Publishing: dry-run, confirm, status](/docs/mcp/publishing.md)
