Skip to content

Annotated custom page recipes

What this is

Two complete custom page bodies you can copy, adapt, and upload with upload-custom-page, each followed by a numbered walkthrough of the decisions that make it pass marker extraction, lint, and the Lighthouse bar on the first try. Recipe 1 is the smallest useful page. Recipe 2 uses the full surface: animation, interactivity, localized media, speakable markup, and a schema.org override. Both use the HTML marker form (data-t-key) because both carry their own <style> and <script> — the Astro/JSX <T> form and the rule for choosing between them is covered below.

Recipe 1 — a bakery page (minimal)

A complete one-screen page for a neighborhood bakery: hero, one content section, a call-to-order button.

<section class="miga-hero">
<h1>Miga Bakery</h1>
<p data-t-key="bakery.hero.tagline">Sourdough and pastries, baked before sunrise.</p>
<a class="miga-cta" href="tel:+34911234567" data-t-key="bakery.hero.cta">Call to order</a>
</section>
<section class="miga-visit">
<h2 data-t-key="bakery.visit.heading">Visit us</h2>
<p data-t-key="bakery.visit.body">We bake Tuesday to Sunday, 7:00 to 14:00, at Calle del Horno 12. Come early — the rye sells out first.</p>
<img class="miga-photo"
src="https://res.cloudinary.com/demo/image/upload/f_auto,q_auto/tf/bakery-counter.jpg"
alt="Pastry counter at Miga Bakery" width="800" height="533" loading="lazy">
</section>
<style>
.miga-hero, .miga-visit { max-width: 640px; margin: 0 auto; padding: 3rem 1.25rem; text-align: center; }
.miga-hero h1 { font-size: 2.5rem; margin: 0 0 0.5rem; }
.miga-cta { display: inline-block; margin-top: 1rem; padding: 0.75rem 1.5rem;
border-radius: 999px; background: #7c4a1e; color: #fff; text-decoration: none; }
.miga-photo { max-width: 100%; height: auto; border-radius: 12px; }
</style>

Walkthrough

  1. Every visitor-facing string carries data-t-key — tagline, CTA label, heading, body. Publish machine-translates the four values into every locale in modules.custom_pages.locales; the markup ships unchanged. Namespaced keys (bakery.visit.body) tell a reviewer where a string lives. Keys must match ^[a-z][a-z0-9_.]*[a-z0-9]$, minimum two characters.
  2. The brand name is not marked. “Miga Bakery” must read identically in every locale, so it stays outside the markers. Numbers, hours, and the street address travel inside marked sentences — translation preserves them. Attribute text (alt, aria-label, placeholders) cannot carry markers and ships as authored to every locale, so keep it short and literal.
  3. The CTA is a tel: link — it works on every phone with zero JavaScript, and href values are never touched by translation. https:, mailto:, tel:, relative, and #anchor URLs all pass upload; javascript:, data:, and vbscript: hrefs are rejected outright.
  4. CSS is inline and class-prefixed. One <style> block means no render-blocking stylesheet request (a <link rel="stylesheet"> without media draws a lint warning) and the page stays a single self-contained upload. Selectors are prefixed .miga- because the body renders inside the platform shell — a bare img { … } selector would restyle it.
  5. The photo has width, height, and loading="lazy". Explicit dimensions reserve the box before the image arrives — zero layout shift. It sits below the fold, so it lazy-loads (the linter flags any non-lazy <img> outside a data-hero region). The Cloudinary URL keeps f_auto,q_auto — the platform never rewrites your URLs to add it.
  6. Upload it with a title (10–60 chars), description (50–160 chars), and a slug (use index for the homepage). Expect markers_extracted_count: 4 and empty lint_findings in the response. The result is a draft — nothing is live until you publish.

Recipe 2 — a product launch page (animated, interactive)

A launch page for a fictional calendar app: keyframe hero entrance, an accessible FAQ accordion, a per-locale screenshot, speakable hero copy, and an Article schema.org type.

<div class="orbit">
<section class="orbit-hero">
<p class="orbit-kicker" data-t-key="launch.hero.kicker">New release</p>
<h1 data-t-key="launch.hero.title">Orbit 2.0 plans around your energy</h1>
<p data-speakable data-t-key="launch.hero.sub">Orbit 2.0 schedules deep work when you focus best and meetings when you do not. Available today on web and mobile.</p>
<a class="orbit-cta" href="https://orbit.example.com/early-access" data-t-key="launch.hero.cta">Join the early access</a>
<div data-hero>
<img src="https://res.cloudinary.com/demo/image/upload/f_auto,q_auto/tf/orbit-ui-en.png"
data-t-media="launch.hero.screenshot" width="1200" height="675"
alt="Orbit 2.0 week view with focus blocks">
</div>
</section>
<section class="orbit-faq">
<h2 data-t-key="launch.faq.heading">Questions, answered</h2>
<h3 class="orbit-faq-q">
<button class="orbit-acc" aria-expanded="false" aria-controls="faq-pricing" data-acc>
<span data-t-key="launch.faq.q1">Does Orbit 2.0 change pricing?</span>
</button>
</h3>
<p id="faq-pricing" hidden data-t-key="launch.faq.a1">No. Every existing plan gets 2.0 at the same price, and the free tier stays free.</p>
</section>
</div>
<style>
.orbit { max-width: 720px; margin: 0 auto; padding: 3rem 1.25rem; }
.orbit-hero { text-align: center; }
.orbit-hero h1, .orbit-hero p, .orbit-cta { animation: orbit-rise 600ms ease-out both; }
.orbit-hero p { animation-delay: 120ms; }
.orbit-cta { animation-delay: 240ms; display: inline-block; margin: 1rem 0 2rem;
padding: 0.75rem 1.5rem; border-radius: 999px;
background: #1d4ed8; color: #fff; text-decoration: none; }
@keyframes orbit-rise {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.orbit-hero h1, .orbit-hero p, .orbit-cta { animation: none; }
}
.orbit img { max-width: 100%; height: auto; border-radius: 12px; }
.orbit-acc { all: unset; cursor: pointer; font-weight: 600; }
.orbit-acc:focus-visible { outline: 2px solid #1d4ed8; outline-offset: 4px; }
.orbit-acc::after { content: " +"; }
.orbit-acc[aria-expanded="true"]::after { content: " \2212"; }
</style>
<script type="module">
for (const btn of document.querySelectorAll(".orbit [data-acc]")) {
btn.addEventListener("click", () => {
const open = btn.getAttribute("aria-expanded") === "true";
btn.setAttribute("aria-expanded", String(!open));
document.getElementById(btn.getAttribute("aria-controls")).hidden = open;
});
}
</script>

The upload call

{
"project_id": "9rTk2WqXa4bYc7LzPdHe",
"project_name": "Orbit Labs",
"slug": "launch/orbit-2",
"title": "Orbit 2.0 — plan your week around your energy",
"description": "Orbit 2.0 adds energy-aware scheduling, automatic focus blocks, and calendar sync. See what is new and join the early-access list.",
"language": "en",
"schema_org_type": "Article",
"og_image": "https://res.cloudinary.com/demo/image/upload/f_auto,q_auto/tf/orbit-og.png",
"published_date": "2026-06-10",
"media_variants": {
"launch.hero.screenshot": {
"es": "https://res.cloudinary.com/demo/image/upload/f_auto,q_auto/tf/orbit-ui-es.png"
}
},
"body_source": "<div class=\"orbit\"> … the full body above, verbatim … </div>"
}

canonical_slug is omitted — it defaults to slug and becomes the durable key that groups all locale variants. schema_org_type: "Article" overrides the default WebPage (allowed: WebPage, Article, AboutPage, ContactPage, CollectionPage, ProfilePage). published_date renders as datePublished in the page’s JSON-LD. The response reports markers_extracted_count: 7 and a draft.

What the per-locale build does

  • publishpublish-confirm machine-translates the seven marked string values, plus title and description, into every locale in modules.custom_pages.locales except the source. The body markup is never translated — every variant carries the same code.
  • At build, each locale’s page splices its translated values into the marker positions: the data-t-key attribute is removed and the element’s text is replaced. Plain-HTML bodies are round-tripped through a standards-compliant parser — markup semantics are preserved, though formatting details such as attribute quoting can normalize. (Astro/JSX bodies ship byte-identical outside marker positions.)
  • The Spanish page resolves launch.hero.screenshot to the es override in media_variants; every locale without an override keeps the inline src. The data-t-media attribute is stripped from the rendered page. Media URLs are never machine-translated.
  • URLs: /launch/orbit-2/ for the source locale, /es/launch/orbit-2/ for Spanish, and so on — with hreflang alternates and x-default generated once two or more live variants share the canonical_slug.
  • data-speakable works because the default speakable selector for custom pages is [data-speakable], h1 + p — the hero paragraph lands in the page’s SpeakableSpecification. A per-page speakable_selector argument replaces the default when you need a different selector.

Walkthrough

  1. Marker strategy. Marked: kicker, headline, subline, CTA label, FAQ heading, question, answer — everything a reader sees. Not marked: the product name standing alone, URLs, class names, ids, the width/height numbers, and anything inside <style> or <script>. Brand names inside marked sentences are preserved by translation. Keep keys stable across re-uploads — a renamed key is a brand-new string to translate.
  2. The FAQ question is marked on a <span> inside the <button>, not on the button itself: marker elements must wrap plain text only, and the button carries state attributes and may later hold an icon.
  3. Animation touches transform and opacity only. Both run on the compositor — no layout work, no jank — and every element animates from its final layout position, so CLS stays zero. The @media (prefers-reduced-motion: reduce) block disables it for users who ask. The hero image keeps explicit width/height, and its data-hero wrapper exempts it — the LCP image — from the lazy-loading lint.
  4. The accordion is a <button> with aria-expanded and aria-controls, toggling the hidden attribute — keyboard-operable and screen-reader-legible by construction, with a visible :focus-visible outline (an all: unset button without a focus style is a classic accessibility-audit failure).
  5. The script is type="module" — deferred by spec, so it draws no render-blocking lint warning. If your project enables the optional per-page Content-Security-Policy, inline scripts must be allowed by its script-src — otherwise move the JS to an external deferred file.

Astro/JSX bodies and the <T> form

If you author the body as an Astro/JSX component, markers use the <T> element instead of data-t-key:

<section class="orbit-hero">
<h1><T key="launch.hero.title" default="Orbit 2.0 plans around your energy" /></h1>
<p data-speakable><T key="launch.hero.sub">Orbit 2.0 schedules deep work when you focus best.</T></p>
</section>

key and default must be static string literals; static inner text works as the default too. At render the whole <T> element is replaced by the translated string, wrapper stripped — keep classes on a parent element. Two boundaries decide which form to use:

  • A body containing <T …> (or starting with --- frontmatter) is parsed as JSX, where every { opens an expression — so a raw <style> or <script> block, whose CSS/JS braces are not JSX expressions, fails to parse and the upload is rejected with Marker extraction failed … JSX/Astro parse error.
  • data-t-key attributes are not extracted on the JSX path. Pick one marker form per body.

In practice: use <T> for markup-only bodies styled by the platform design system; the moment a page carries its own <style> or <script> — like both recipes here — use the HTML form.

Patterns that always pass

  • One marker form per body; data-t-key whenever the page carries its own <style>/<script>.
  • Namespaced keys (page.section.element) matching ^[a-z][a-z0-9_.]*[a-z0-9]$, minimum two characters; one default per key; markers wrap plain text only.
  • Mark every string a reader sees; never mark brand names standing alone, code, numbers, or URLs. Attribute text ships as authored.
  • href values are https:, mailto:, tel:, relative, or #anchorjavascript:, data:, and vbscript: hrefs are rejected at upload.
  • Animate transform and opacity only; honor prefers-reduced-motion.
  • Every <img> has width, height, and a descriptive alt; below-the-fold images get loading="lazy"; the LCP image lives in a data-hero region; Cloudinary URLs carry f_auto,q_auto.
  • Inline, class-prefixed <style>; scripts are type="module" or defer.
  • Self-contained: no render-blocking external stylesheets; keep body_source well under the 500 KB soft cap (2 MB hard cap).
  • title 10–60 characters, description 50–160; omit canonical_slug on new pages.
  • Build to ≥95 in all four Lighthouse categories — performance, accessibility, best practices, SEO. The pre-publish audit holds custom pages to that bar.

Last updated: