Landing page and themes

The landing page as a list of blocks in one config file, the block shapes you can use, the four built-in themes, and product screenshots.

The landing page is data. src/config/landing.ts exports an array of blocks, and src/routes/_marketing/index.tsx does nothing but render it — the route holds no copy and no layout. The look of the whole app, landing page included, comes from a theme: a set of CSS custom properties, with four built in.

Editing the landing page

Everything in landing.ts is the template's own pitch and is meant to be replaced. The common edits are one-line changes:

ToDo
Reorder sectionsMove an entry in the array
Remove a sectionDelete its entry
Add a sectionAdd an entry with one of the block shapes below
Change copyEdit the message in messages/en.json and messages/zh.json

A block is an object with a type and that shape's fields:

import { m } from '@/paraglide/messages'

export const landing: readonly LandingBlock[] = [
  {
    type: 'hero',
    title: () => m.marketing_hero_title(),
    subtitle: () => m.marketing_hero_subtitle(),
  },
  {
    type: 'faq',
    kicker: () => m.marketing_faq_kicker(),
    title: () => m.marketing_faq_title(),
    items: [
      { q: () => m.marketing_faq_cost_q(), a: () => m.marketing_faq_cost_a() },
    ],
  },
  {
    type: 'cta',
    title: () => m.marketing_cta_band(),
    docs: () => m.marketing_cta_band_docs(),
  },
]

Every string is a function

Copy is always written as () => m.key(), never m.key(). The config module is evaluated once per Worker isolate, but the locale belongs to each request. A bare call would freeze whichever language loaded the module first and serve it to everyone. The Text type in src/components/marketing/blocks/types.ts enforces this, so a bare string or call fails typecheck. See Internationalization.

Tone

Most blocks take an optional tone: 'plain' (the page ground, the default) or 'sheet', which lays a rounded grey sheet under the section. Alternating the two gives a long page rhythm without images. One rule: blocks whose own cards use the surface colour — modules, bento, chips, pricing, faq, compare, gallery, testimonials — must stay plain, or the cards disappear into the sheet.

Block shapes

All shapes are one union type, LandingBlock, in src/components/marketing/blocks/types.ts, and each is implemented in its own file beside it.

TypeWhat it renders
heroTitle, subtitle, the main call to action and a docs link
logosRows of brand marks, such as your stack
logos-groupedBrand marks in labelled groups
chipsNamed chips in groups, for things with no brand mark
statsBig numbers that count up when scrolled into view
terminalA shell session, line by line
stepsNumbered steps, each with its own shell block
surfacesFull-width product screenshots that swap as the reader scrolls
modulesA ledger of what is included: a name and short chips per row
bentoAn uneven grid of cards
compareA comparison table
galleryThings people built with your product; renders nothing when empty
testimonialsCustomer quotes on moving cards
pricingYour plans, read from src/config/plans.ts
faqFolding questions and answers
ctaThe closing call to action

The pricing block has no prices of its own: it reads the same src/config/plans.ts that checkout uses, so the page cannot advertise a price you do not charge (see Payments). The main call-to-action target in hero and cta is /dashboard, or /waitlist when the waitlist is switched on in src/config/app-config.ts.

The block palette at /blocks

The default page uses only some of the shapes. Run bun run dev and open /blocks to see every shape rendered with sample data. It is a workbench, so its copy is plain English strings. Copy the object you want into landing.ts and replace its strings with message thunks. The page renders nothing in a production build.

Adding a shape

Add a member to the LandingBlock union, a component in a new file under src/components/marketing/blocks/, an export in blocks.tsx, and a case in the switch in render.tsx. The switch is exhaustive, so a missing case is a type error rather than a section that silently renders nothing.

Before launch

The default page ships a testimonials block with invented people and placeholder avatars. Replace every item with a real customer's words, used with their permission, or delete the block. Leaving it in publishes invented reviews.

Screenshots and media

The surfaces block shows real screenshots of the app. They are committed files in public/marketing/, one per theme mode: admin-light.webp and admin-dark.webp, and so on. Only the current mode's image is downloaded.

To refresh them after the UI changes:

bun run dev                 # in one terminal
bun run capture:marketing   # all shots; `bun run capture:marketing admin` for one

The script seeds the local database, signs in with test sessions, drives the real app with Playwright, and writes the files. It refuses to save a capture that shows an email address outside the seed data's fake domains, because the admin screens show whatever is in your local database. It converts PNGs to WebP with cwebp if that is installed. Adding a surface means adding an entry to SHOTS in scripts/capture-marketing.ts as well as to landing.ts.

For heavier assets you do not want in git, such as video, the <Frame> and <Clip> components in src/components/marketing/media.tsx read from appConfig.mediaUrl, under a marketing/ prefix. With mediaUrl empty (the default) they render a quiet placeholder rather than a broken image. bun run upload:marketing <dir> resizes the images in a folder to WebP and uploads them with wrangler. Note that it uploads to the first bucket in wrangler.jsonc, the same bucket that holds private user files; if you give that bucket a public domain to serve as mediaUrl, consider a separate bucket for marketing media instead (see File storage).

Themes

Four visual styles are built in, registered in src/config/themes.ts:

ThemeLook
shipkit (default)Neutral and blue, soft corners
editorialSerif, square corners, printed paper
softLilac, round corners, gentle shadows
brutalMonospace, hard edges, loud yellow

Each has a light and a dark face. Two settings combine independently:

  • The style is the data-theme attribute on <html>, set by src/components/theme-provider.tsx. The reader's choice is kept in localStorage, and a small inline script applies it before first paint so the page never flashes the default.
  • The mode, light or dark, is the dark class, handled by next-themes.

Readers pick a style from the menu in the marketing header or the Style row in the account settings; mode has its own toggle.

A theme is only tokens

The default theme lives in :root and .dark in src/styles.css; the other three are blocks in src/styles/themes.css under :root[data-theme="…"] and :root[data-theme="…"].dark. No component reads data-theme. Everything a theme changes is a CSS custom property: colours such as --background, --primary and --brand, and shape tokens:

TokenControls
--radius-btnButton corners
--radius-cardCard corners
--radius-sheetLarge sheets and bands
--shadow-surfaceShadow on cards, dialogs and menus
--shadow-raisedShadow on buttons
--font-bodyBody typeface
--font-displayMarketing headline typeface

That is why a hand-written button uses rounded-[var(--radius-btn)] rather than rounded-full. If a new look needs a component to change shape, add a token for that shape and set it in each theme, instead of branching in the component.

Changing the default or adding a theme

To ship in a different look, change defaultTheme in src/config/themes.ts. If you want one look with no choice at all, also remove ThemePicker from src/routes/_marketing.tsx and the Style row (ThemeRow) from src/features/auth/components/account-section.tsx.

To add a theme, add an entry to themes (id, label and hint messages, and three swatch colours for the picker) and write a light block and a dark block in src/styles/themes.css. The no-flash script is generated from the registry, so it needs no edit. The design rules for the default look — colour, radius, buttons — are in src/CLAUDE.md.