Build a marketing landing page
A marketing landing page is mostly static: a hero, a few feature cards, a
pricing table, a call to action. renderApexPage turns a typed ApexPage
description into one complete HTML string — server-side, zero React, one
cacheable HTTP response. No build step, no hydration.
The renderer lives in @cosmicdrift/kumiko-headless/apex. It ships two themes
(light and dark) and a fixed set of section kinds; your app passes only data
and its brand tokens.
Compose it from your features
Section titled “Compose it from your features”The point isn’t the HTML — it’s where the content comes from. A landing page is the worst place to hard-code copy and prices: they go stale, and the marketing page quietly drifts from the actual product. Instead, pull each piece from the feature that already owns it:
- Hero headline & tagline ←
text-content— editable copy, keyed by slug, changed without a deploy. - Meta title & description ←
text-content— the same blocks, reused for the page title and SEO. - Prices & per-month suffix ←
tier-engine— the amounts from your plan config, one source of truth. - Plan caps (“50 projects”) ←
tier-engine/ cap config — the same limits the app enforces. - Sign-in / sign-up links ←
auth-email-password— the real auth routes.
The renderer stays dumb on purpose. It does not fetch, query, or know about any feature — your app reads those features and hands the renderer plain data. That keeps the page a pure function of its inputs, and trivially testable.
The two seams
Section titled “The two seams”Hero copy from text-content. Look up each block by slug, with a baked-in
fallback so the page renders fully even before anything is seeded:
function block(blocks: ContentBlocks | undefined, slug: string, fallback: string): string { return blocks?.get(slug) ?? fallback;}
// in the hero section:title: block(input.blocks, "index:hero.title", "Ship your roadmap, not your spreadsheet"),Prices from tier-engine. Map each plan from your tier config onto a
pricing card — the amount, the per-month suffix, and the cap all come from the
config the app already enforces, so the table can’t lie:
function toPricingTier(plan: PlanInfo): ApexPricingTier { const paid = plan.monthlyEur !== null; return { name: plan.name, amount: paid ? formatEuro(plan.monthlyEur) : "0 €", priceSuffix: paid ? "/month" : undefined, capLine: Number.isFinite(plan.maxProjects) ? `${plan.maxProjects} projects` : "Unlimited projects", benefits: plan.benefits, cta: { label: `Choose ${plan.name}`, href: "/signup" }, };}Try it
Section titled “Try it”The full, runnable example — both seams, a test that proves the fallback and
the price formatting, and the screenshot above — is the
apex-landing recipe. It embeds its own
source, so the code on that page is exactly what runs.
The screenshot is not hand-captured: bun run screenshot renders the sample
page and shoots it with Playwright (page.setContent — no server needed,
because the renderer is pure). CI regenerates it with SCREENSHOT_DIR pointed
at public/screenshots/apex/, so the published image can’t drift from the
renderer; a plain local bun run screenshot writes to the recipe’s own
screenshots/ folder for a quick preview.
Section kinds
Section titled “Section kinds”An ApexPage is a head, a header, a footer, and an ordered list of
sections. Each section is one of:
hero— logo, headline, tagline, CTAs, optional screenshot.feature-grid— eyebrow, heading, and a grid of icon + title + text cards.pricing-grid— the plan cards, oneApexPricingTiereach.info-grid— a lighter grid for trust / FAQ / “how it works” blurbs.final-cta— closing headline and a single call to action.html— an escape hatch for one app-specific section of raw HTML.
Themes switch on theme: "light" | "dark"; both CSS sets ship and the body
class toggles. Brand colours come from brand.tokensCss — the :root token
block your app already owns, passed through verbatim.