Skip to content

Build a marketing landing page

A marketing landing page is mostly static: a hero, feature cards, pricing, and a call to action. renderApexPage turns an ApexPage into one server-rendered HTML string. It does not require React or hydration.

The renderer lives in @cosmicdrift/kumiko-headless/apex. Your app passes the page data and brand tokens. The renderer supplies the light/dark themes and the supported section types.

Rendered Apex landing page, hero, feature grid, pricing table, final call to action

Keep landing-page content in the features that own it. Hard-coded copy and prices go stale, which makes the marketing page drift from the product. Pull each piece from its existing feature:

  • Hero headline & tagline ← template-resolver, editable copy, keyed by slug, changed without a deploy.
  • Meta title & description ← template-resolver, 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 does not fetch data or know about your features. Read the values in your app and pass plain data to it. This keeps renderApexPage a pure function.

Hero copy from template-resolver. Look up each block by slug and provide a fallback for an empty store:

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 the same tier config that enforces its limits:

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" },
};
}

The apex-landing recipe contains both seams, tests for the fallback and price formatting, and the screenshot above. Its embedded source is the runnable implementation.

Run bun run screenshot in the recipe to regenerate the local screenshot. CI sets SCREENSHOT_DIR to the published Apex screenshot directory.

The head object is also where canonical, Open Graph, Twitter, and JSON-LD metadata belongs. renderApexPage emits those tags; the renderer does not derive them from feature data for you.

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 (click .shot-frame to enlarge).
  • feature-grid, eyebrow, heading, and a grid of icon + title + text cards.
  • pricing-grid, the plan cards, one ApexPricingTier each.
  • 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.

Screenshots inside .shot-frame (hero and custom html showcase rows) open in a built-in lightbox, vanilla <dialog>, no React:

Apex lightbox open after clicking the hero screenshot

Details: Dialog and Lightbox overlays.