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
- Every visitor-facing string carries
data-t-key— tagline, CTA label, heading, body. Publish machine-translates the four values into every locale inmodules.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. - 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. - The CTA is a
tel:link — it works on every phone with zero JavaScript, andhrefvalues are never touched by translation.https:,mailto:,tel:, relative, and#anchorURLs all pass upload;javascript:,data:, andvbscript:hrefs are rejected outright. - CSS is inline and class-prefixed. One
<style>block means no render-blocking stylesheet request (a<link rel="stylesheet">withoutmediadraws 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 bareimg { … }selector would restyle it. - The photo has
width,height, andloading="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 adata-heroregion). The Cloudinary URL keepsf_auto,q_auto— the platform never rewrites your URLs to add it. - Upload it with a
title(10–60 chars),description(50–160 chars), and aslug(useindexfor the homepage). Expectmarkers_extracted_count: 4and emptylint_findingsin 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
publish→publish-confirmmachine-translates the seven marked string values, plustitleanddescription, into every locale inmodules.custom_pages.localesexcept 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-keyattribute 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.screenshotto theesoverride inmedia_variants; every locale without an override keeps the inlinesrc. Thedata-t-mediaattribute 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 andx-defaultgenerated once two or more live variants share thecanonical_slug. data-speakableworks because the default speakable selector for custom pages is[data-speakable], h1 + p— the hero paragraph lands in the page’sSpeakableSpecification. A per-pagespeakable_selectorargument replaces the default when you need a different selector.
Walkthrough
- 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/heightnumbers, 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. - 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. - Animation touches
transformandopacityonly. 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 explicitwidth/height, and itsdata-herowrapper exempts it — the LCP image — from the lazy-loading lint. - The accordion is a
<button>witharia-expandedandaria-controls, toggling thehiddenattribute — keyboard-operable and screen-reader-legible by construction, with a visible:focus-visibleoutline (anall: unsetbutton without a focus style is a classic accessibility-audit failure). - 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 itsscript-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 withMarker extraction failed … JSX/Astro parse error. data-t-keyattributes 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-keywhenever 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.
hrefvalues arehttps:,mailto:,tel:, relative, or#anchor—javascript:,data:, andvbscript:hrefs are rejected at upload.- Animate
transformandopacityonly; honorprefers-reduced-motion. - Every
<img>haswidth,height, and a descriptivealt; below-the-fold images getloading="lazy"; the LCP image lives in adata-heroregion; Cloudinary URLs carryf_auto,q_auto. - Inline, class-prefixed
<style>; scripts aretype="module"ordefer. - Self-contained: no render-blocking external stylesheets; keep
body_sourcewell under the 500 KB soft cap (2 MB hard cap). title10–60 characters,description50–160; omitcanonical_slugon 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.
Related
- Custom pages overview
- Author a custom page — the contract and the loop, end to end
- Translation — the full marker reference
- Quality gates — the Lighthouse bar in detail
- Knowledge vs custom pages
- Publishing — dry-run, confirm token, status