Skip to main content

Marketing site & landing page

The public marketing site lives in apps/marketing/ and is driven by a single config file: apps/marketing/src/landing.config.ts. You customize copy, theme, sections, SEO, and navigation there; the pricing page pulls live recurring products from your database at build time.

How it works

Here is how a page request flows from config to rendered HTML, and where pricing data joins the build.
📖 See also: Billing architecture for how products, checkout, and subscriptions relate to what visitors see on /pricing.

The config file

Almost everything marketing-specific is declared in apps/marketing/src/landing.config.ts. Treat it as your launch checklist — the file header lists pre-production SEO and legal tasks.
SITE_URL resolves from APP_URL or PUBLIC_SITE_URL at build time (apps/marketing/src/config/landing/siteUrl.ts). Canonical URLs, Open Graph links, and the sitemap all depend on it.
Set APP_URL to your real production domain before launch. A wrong value produces incorrect canonicals and sitemap URLs even if the site looks fine in the browser.

Theme

The marketing site uses a token-based theme separate from the portal MUI theme. Colors, radii, fonts, layout spacing, and shadows are defined in apps/marketing/src/config/landing/theme.ts and compiled to CSS variables at runtime via buildLandingThemeCss(). Pick a preset in landing.config.ts:
Customize a preset by spreading DEFAULT_THEME and overriding tokens (see presets/saas.ts). Light / dark / system is handled client-side. The footer theme selector writes marketingThemeMode to localStorage and sets data-landing-theme on document.documentElement. GDPR cookie UI and pricing cards read the same active tokens, so they follow the selected mode automatically.

Homepage sections

The homepage is a ordered array of typed sections. Define them in apps/marketing/src/config/landing/sections/home.ts and pass the export to sections in landing.config.ts. Minimal hero example:
Each section needs a stable id (used for analytics data-marketing-section attributes). CTAs use CtaLink.astro, which emits data-marketing-event="landing_cta_clicked" for tracking. Icons in split/feature sections use names resolved by LandingIcon.astro (credit-card, shield-check, etc.). Images in public/ are referenced with paths like /brand/hero-screenshot.svg; resolvePublicAsset() handles base URL when needed.

SEO & discovery

Per-page SEO and indexable static routes both come from landing.config.ts → pages.*, plus global seo defaults. Blog URLs are separate — Postgres is the SSOT for articles, pillars, and series. Document title format: Page title · Site name unless the page title equals the organization name (home).
Set noindex: true on a PageSeoConfig to exclude a page from sitemap and llms.txt.

Adding a static marketing page

  1. Add an entry under pages.* in landing.config.ts (extend PagesConfig in types.ts if you add a new key).
  2. Create a matching src/pages/.../index.astro that calls buildSeo(landingConfig.pages.<key>, landingConfig).
  3. Run make build module=marketing so discovery-static-pages.json updates.
  4. Discovery regen runs on backend startup (when marketing.staticPath is set), on blog publish/update/delete, or on the scheduled tick.
  5. Verify /sitemap.xml and /llms.txt on APP_URL.
Header (SiteHeader.astro) reads navigation.primaryLinks and navigation.headerCta from config. Footer (SiteFooter.astro) reads navigation.footerLinks, navigation.copyright, and social.links. Announcement bar — toggle with navigation.announcement.enabled and set text + optional href. Social links are plain labeled anchors (opened in a new tab):
platform is metadata for your own organization; rendering uses label and href. Add or remove entries freely — an empty array hides the social row.

Pricing page

/pricing/ renders plans fetched at build time (SSG). Cards are not hard-coded in the Astro template.

What gets included

Backend publicProducts with type: recurring returns recurring products where in_stock is null or greater than zero, sorted by position, with active prices included (publicPricing.ts). Each product includes:
  • name, prices[] (price in display dollars, currency, billing_period, is_default), top-level billing_period from the default price
  • description JSON (text + optional features)
  • trialable, is_default, seat/usage metadata derived from the default price
Marketing then:
  1. Drops products with is_default: true (your free/default tier stays out of public pricing).
  2. Maps description onto card fields (see table below).
  3. Builds CTA links to signup → billing checkout for that product id.

Product description → card mapping

Store this JSON on the product description column (admin / editProduct):
Usage-based bands from the default price’s usage metadata append as extra feature lines automatically.

Billing period toggle

When pricing.billingToggle is true and a plan’s prices[] includes both monthly and yearly rows, PricingDisplay shows a Monthly / Yearly control and picks the matching price via billingPeriod. Cards carry data-billing-period and toggle visibility client-side.

Build-time fetch and fallback

GraphQL URL resolution (graphqlPricingProvider.ts): Currency for publicProducts (resolveMarketingPricingCurrency): Marketing (and publicProducts) resolve one currency per request. Refract does not offer multi-currency subscription migrations; changing a live plan between currencies (for example EUR → USD) requires cancel + re-subscribe. See Billing — Single currency.
For local marketing builds without the API up, set MARKETING_PRICING_BUILD_FALLBACK=dev-snapshot or keep pricing.onBuildFetchFailure: "dev-snapshot" in config. Update apps/marketing/src/config/landing/pricing.dev-snapshot.json when you need representative cards offline.

Enterprise row

Below the grid, pricing.enterpriseCta renders a “Need a custom plan?” band linking to contact (or any LandingCta you configure).

Other pages

Legal pages validate required placeholders via validateLegalConfig — replace LEGAL_PLACEHOLDER_* values in landing.config.ts before production.

Local development

Marketing runs as the marketing service in Docker Compose. With make start, Caddy on port 8888 serves marketing routes on the unified origin (APP_URL). Marketing owns the root dev namespace (/@vite, /src, /_astro/*); portal dev assets live under /__portal/ (see Configuration). New pages under apps/marketing/src/pages/ need no Caddyfile.dev edit — the marketing catch-all handles them.
GDPR consent, Google Analytics (consent-gated), and marketing event tracking are wired in GdprBody.astro and marketingAnalytics.ts. See GDPR consent flow and Frontend tracking.

Extending the marketing site

  1. Brand copy — Edit apps/marketing/src/landing.config.ts (organization, navigation, SEO, social).
  2. Homepage layout — Reorder or edit sections in apps/marketing/src/config/landing/sections/home.ts.
  3. Theme — Swap or fork a preset in apps/marketing/src/config/landing/presets/.
  4. Product cards — Update product description JSON in admin; set position and is_default on recurring products.
  5. New section type — Add the TypeScript type, Astro component, and SectionRenderer case.
  6. Verify — make build module=marketing and spot-check /, /pricing/, and Lighthouse SEO on home.
Example: add a new split section to the homepage.

What not to do

❌ Never duplicate your product catalog in marketing config — pricing comes from publicProducts at build time.
❌ Never expose the default (is_default) product on /pricing/ — it is intentionally filtered out.
❌ Never ship with LEGAL_PLACEHOLDER_* or example social URLs still in landing.config.ts.
❌ Never hard-code checkout URLs without the signup redirect wrapper — use buildBillingCheckoutSignupHref() from apps/marketing/src/config/landing/appLinks.ts so post-auth routing works.

File conventions

What’s next?