---
title: "Annotated custom page recipes"
description: "Two annotated, copy-paste-ready custom page examples — minimal HTML and an animated interactive launch page — plus a checklist of patterns that pass."
url: "https://comstack.ai/docs/custom-pages/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.

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

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

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

```astro
<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 `#anchor` — `javascript:`, `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.

## Related

- [Custom pages overview](/docs/custom-pages.md)
- [Author a custom page](/docs/custom-pages/authoring.md) — the contract and the loop, end to end
- [Translation](/docs/custom-pages/translation.md) — the full marker reference
- [Quality gates](/docs/custom-pages/quality.md) — the Lighthouse bar in detail
- [Knowledge vs custom pages](/docs/mcp/page-types.md)
- [Publishing](/docs/mcp/publishing.md) — dry-run, confirm token, status
