Skip to main content

Marketing Site

/site is a public marketing brochure — home, features, pricing, about, contact, legal, and the desktop runner's page — served at the root domain. /web is the authenticated dashboard. They are two separate Next.js projects, not one project with two route groups.

Visual design system​

/site went through a 5-phase visual redesign (purple/white/near-black palette, Chakra Petch + JetBrains Mono type system, decorative hairline primitives, and a WebGL 3D hero) after this file was first written. See docs/site-design-system.md for the palette tokens and their measured WCAG contrast ratios, the font/decoration/3D rationale, the SSR-isolation and reduced-motion rules for the 3D scenes, and the bundle code-split contract that keeps them off every non-homepage route. Nothing in that redesign changed the routing split, domain contract, consent behavior, or legal/SEO plumbing described below — those remain exactly as documented here.

Why a separate project instead of a route group in /web​

  • Different audience, different trust boundary. Every page in /site is meant to be crawled, indexed, and viewed by anonymous visitors with no session. Every page in /web requires a cookie and is deliberately kept out of search engines (see robots.ts/sitemap.ts in /site — neither exists in /web, whose middleware guards every route instead). Folding both into one Next app would mean threading "is this route public" through the same middleware that currently does one job: enforce auth.
  • Different tech needs. /web is built on Mantine v8 for the component-heavy authenticated UI (tables, forms, drag-and-drop workflow builder). A marketing site doesn't need a component library at that scale — it needs fast static pages, so /site uses Tailwind CSS v4 directly and ships no Mantine at all. Pulling Mantine into pages that exist purely to be crawled and to load fast would add bundle weight, and pulling marketing-site concerns (JSON-LD, OG image generation, cookie consent) into /web would add weight in the other direction.
  • Independent deploy/build lifecycle. /site has no backend dependency (no depends_on in docker-compose.yml) and must come up even if the entire backend stack is down — a marketing page returning an error because Postgres is unreachable is a worse failure mode than the whole platform being down. /web depends on inference and go-api. Two Next projects means each one's healthcheck, depends_on, and rebuild only touches what it actually needs.

The routing split​

Nginx is still the single entry point. The split happens in nginx/locations.conf, at the very end of the file, using the two upstream variables declared per-scheme just above the routing table (nginx/http-server-dev.conf for :80, nginx/https-server.conf.template for :443):

set $web_app http://web:3000;
set $site_app http://site:3000;

and the two trailing location blocks:

# Dashboard (Next.js `web`, basePath '/app').
location ^~ /app {
proxy_pass $web_app;
...
}

# Public marketing site.
location / {
proxy_pass $site_app;
...
}

location ^~ /app wins over every regex location above it (SSE, /stop, etc.) without further regex evaluation, and web itself runs with Next's basePath: '/app', so proxy_pass $web_app (a bare variable, no URI segment) forwards /app/... through unchanged — exactly what a basePath'd app expects to receive. Everything that isn't /api/v1/*, /health, or /app/* falls through to the trailing location /, which proxies to site. Every location block before these two (auth, admin, workflows, the SSE regex, the /stop regex, health, workflow-engine, the sandbox-executor's deliberate non-block, the generic /api/v1/ catch-all) is unrelated to this split and untouched by it.

Why web's healthcheck probes /app/login, not /​

web runs with basePath: '/app', so a request for / inside that container 404s — Next simply doesn't have a route there. docker-compose.yml's original healthcheck (wget .../ ) predates the basePath change and would now report web unhealthy forever. That matters beyond a red status dot: nginx's depends_on on web uses condition: service_healthy, so an unhealthy web would mean nginx itself never starts, taking the entire stack down over a healthcheck that was probing a route that doesn't exist. The healthcheck was moved to a real, unauthenticated route under the basePath — /app/login — which 200s regardless of auth state.

The single-swap domain contract​

Every place in /site that needs the real, absolute domain — canonical URLs, sitemap.xml, robots.txt's sitemap/host fields, Open Graph and Twitter card URLs, JSON-LD url fields — reads it from exactly one place: siteConfig.url in site/lib/site-config.ts, which in turn reads process.env.NEXT_PUBLIC_SITE_URL (falling back to https://reelbolt.ai if unset). buildMetadata() in site/lib/seo.ts is the only place page-level metadata is constructed, and it always resolves URLs through siteConfig.url. There is deliberately no second place that hardcodes a domain — swap NEXT_PUBLIC_SITE_URL once and every page, feed, and structured-data block follows.

Launch checklist​

Work through this before pointing real traffic (and real search engines) at the site.

  1. Set the real domain. NEXT_PUBLIC_SITE_URL in .env — see "single-swap domain contract" above. This is a build-time arg (site/Dockerfile passes it to next build), so the site image must be rebuilt after changing it, not just restarted.

  2. Fill in every bracketed placeholder in site/lib/legal-placeholders.ts. Each constant is an intentionally unfilled [TOKEN] — nobody but the business owner has these facts, so none of them were invented. The full list of tokens still to fill:

    • COMPANY_LEGAL_NAME — the registered legal entity name
    • REGISTERED_ADDRESS — registered/business address shown in the footer and legal pages
    • COMPANY_REGISTRATION_NUMBER — company/business registration number
    • VAT_NUMBER — VAT or equivalent tax ID
    • SUPPORT_EMAIL — general contact/support address
    • PRIVACY_EMAIL — privacy-specific contact address (may equal SUPPORT_EMAIL)
    • DPO_CONTACT — data protection officer contact, if one is required/appointed
    • GOVERNING_LAW — the jurisdiction whose law governs the terms
    • COURTS_JURISDICTION — which courts have jurisdiction over disputes
    • LAST_UPDATED — the date the legal pages were last revised
    • PHONE_NUMBER — business phone number
    • SUPERVISORY_AUTHORITY — the data-protection authority visitors can complain to
    • CONTACT_RETENTION_PERIOD — how long contact-form submissions are retained
    • LOG_RETENTION_PERIOD — how long server/access logs are retained
    • LIABILITY_CAP — the liability limitation figure/formula in the terms
    • SERVICE_AGREEMENT_REFERENCE — reference to any separate master service agreement
    • TRANSFER_MECHANISM — the mechanism (e.g. SCCs) governing any international data transfer
    • RESPONSE_TIME_SLA — the response-time commitment shown on the contact page

    The remaining facts the site needs were supplied by the owner during H7 (subscriptions and the desktop runner) and are already filled in the file, which is why they are intentionally absent from the list above: HOSTING_PROVIDER (Hetzner, Falkenstein, Germany), EMAIL_PROVIDER (Cloudflare — to be confirmed: the owner hedged with "probably"), STORAGE_PROVIDER (Cloudflare R2), PAYMENT_PROVIDER (Paddle, merchant of record), MODEL_PROVIDERS (DeepSeek primary chat today, plus OpenAI, Azure OpenAI, Anthropic, MiniMax, Higgsfield and Fish Audio), REFUND_POLICY, CREDIT_EXPIRY_PERIOD (1 year), CONTENT_RETENTION_PERIOD (3 years) and RUNNER_EULA_LAST_UPDATED (October 8th, 2026).

  3. Analytics. Set NEXT_PUBLIC_GA_MEASUREMENT_ID to a real GA4 measurement ID (G-XXXXXXXXXX), or leave it empty to ship with no analytics at all — no script is injected either way unless a visitor also accepts the cookie banner (see below).

  4. Contact form delivery. Set CONTACT_WEBHOOK_URL if submissions should be POSTed onward (e.g. to a CRM or Slack webhook) instead of only being logged to the site container's stdout.

  5. Switch the build to production. Set SITE_BUILD_TARGET=production and SITE_NODE_ENV=production in .env, and comment out or remove the dev-only volume mounts under docker-compose.yml's site service (./site:/app, /app/node_modules, /app/.next) — those mounts exist for Turbopack hot reload and would shadow the precompiled standalone build the production Dockerfile target produces.

  6. Submit to search engines. Once the real domain is live and serving real content, submit https://<domain>/sitemap.xml to Google Search Console (and any other search console you use). The site also serves https://<domain>/llms.txt (site/app/llms.txt/route.ts), a plain-text summary for AI assistants; it is generated from the same siteConfig/HOME_FAQS sources as the pages, so it needs no separate upkeep.

  7. Turn on "Start free", once self-serve signup is live (decision D25). Every primary CTA on the site reads siteConfig.primaryCta (site/lib/site-config.ts, rendered through site/components/ui/PrimaryCta.tsx). With NEXT_PUBLIC_SIGNUP_ENABLED unset or anything but true — the default, and what a plain docker build or npm run build:pages produces — it is "Talk to us" pointing at /contact, and the header/footer keep their "App · Coming soon" state. Set it to true and the same source ships "Start free" pointing at /app/signup, with a "Sign in" link to /app/login where the coming-soon marker was. It is build-time (NEXT_PUBLIC_* is inlined, and the Pages export has no server), so changing it needs a rebuild — site is rebuilt by the Docker build arg of the same name. Flip it only when /app/signup actually answers: the flag is a display switch, and POST /api/v1/auth/signup still 404s unless the Go API itself has SIGNUP_ENABLED. The value is read through the literal process.env.NEXT_PUBLIC_SIGNUP_ENABLED member expression in site/lib/site-config.ts on purpose: read as a property of a passed-in object it compiles and renders correctly on the server, then silently evaluates to undefined in the browser bundle, so every client component (the header, the sticky mobile CTA, every PrimaryCta) re-renders the old state on hydration while the server components keep the new one. H6 found and fixed that; the comment there is the warning.

  8. Turn on the desktop runner, once there is something to download. /runner ships as its "not available yet" state, and it is noindex, absent from sitemap.xml and absent from llms.txt. The switch is not a flag: site/lib/runner-downloads.ts exports an artifact list that is empty today, and adding E10b's signed installers to it turns the page, the sitemap entry, the llms.txt line and the noindex off together (site/lib/runner-downloads.test.ts fails on purpose the moment that happens, so the live state is reviewed against a real release). Add the requirements, pairing explainer, checksum/signature note and the runner EULA link at the same time — E6, E9 and H7 own those facts and none of the three exists yet, so the page carries none of them rather than guessing.

Who the copy is written for​

The marketing pages address a buyer — a founder, product marketer or growth lead deciding whether to use ReelBolt — not an engineer evaluating the stack. That is a deliberate split:

  • Marketing pages say what you get. "Upload the raw take, get back the finished cut." Implementation names (ffmpeg, Remotion, Docker, PostgreSQL, RabbitMQ, Qdrant, Docusaurus, the workflow engine, the agent-room architecture) are out of scope for /, /features and /about. A buyer does not choose a video tool on which encoder it shells out to, and the words cost comprehension.
  • So are third-party vendor names. The speech, video-generation and decision-model services a deployment connects (Fish Audio, MiniMax, TypeSafe, Higgsfield, ComfyUI, and any later one) are described by role — "the speech service you connect", "a video-generation service" — never by name, on the same three pages. They are the customer's vendor relationship, not ReelBolt's feature, and naming one would imply an endorsement or a bundled licence that does not exist. grep -niE 'ffmpeg|remotion|docker|postgres|rabbitmq|fish audio|minimax|typesafe|higgsfield|comfyui' site/app/page.tsx site/app/features/page.tsx site/app/about/page.tsx site/lib/faqs.ts site/lib/structured-data.ts site/app/llms.txt/route.ts site/components/home/*.tsx must print nothing. (The trailing site/components/home/*.tsx — the Hero and the other homepage components — was added by the H8 copy refresh: the components sit outside the three page files but feed them, and the H8 acceptance grep covers them.)
  • Claims track what is on master, not what is in a PR. The 2026-10 feature refresh (issue #159) is the worked example: narration is "the script spoken word for word in a voice you choose" because that is what shipped — no voice cloning, no dubbing, no caption output, so none of those appear; the studio view is "a window onto the edit, not a replacement for your editor" because the editable timeline is still unmerged, so the site never says "edit"; generated footage is "a handful of short clips under a hard spending cap" because that is the shipped planner + budget gate, and no second generation vendor is implied. When a feature PR lands, the copy may grow; until then it must not.
  • CLAUDE.md and docs/ say how it works. That is where the stack belongs, and it stays exhaustive there.
  • One deliberate exception: the open-source attribution clause in site/app/legal/terms/page.tsx still names Remotion and ffmpeg. That is a licensing acknowledgement, not marketing copy, and it must stay.
  • A second exception, granted and not yet exercised: /runner may name a container app. The desktop runner renders in a container it manages (decision D15: Docker Desktop or Podman, and without one the runner advertises ffmpeg-only capabilities), so when that page carries install instructions the buyer has to be told which container app to install, and "a container runtime" is the jargon instead of the plain word. The permission is narrow: name the container app only where the buyer must install one, never as an architecture boast, and never on /, /features or /about. It is not exercised today — /runner currently ships its "not available yet" state, which has nothing to install, so it names no vendor at all. The /runner page is outside the three-page rule above, but the rule's grep does not cover it either; keep the check honest by adding site/app/runner/page.tsx to the next copy sweep rather than by loosening the rule.

When adding a page or a section, write the benefit first and check it against CLAUDE.md second. If a sentence cannot be traced to a real capability there, it does not ship.

Structured data​

site/lib/structured-data.ts emits, per page:

Schema typeWhereSource
Organization, WebSiteevery page (root layout)siteConfig
SoftwareApplication/siteConfig + a featureList of real capabilities (editing, graphics, grading/music/SFX, narration, budget-capped generated b-roll, object tracking and blurring, review loop, team workspaces, optional own-computer rendering, BYO provider/self-hosting) — every entry maps to a section on /features. operatingSystem stays 'Web-based' deliberately: the desktop runner's signed installers are not released, so the runner platforms are not claimed yet; the value grows in the same commit as site/lib/runner-downloads gains artifacts
FAQPage/site/lib/faqs.ts — the same array the page renders
AggregateOffer/added to the SoftwareApplication by lib/pricing/offers.ts — lowPrice/highPrice are the cheapest and dearest recurring plan prices, and offerCount counts the offers actually emitted
Product + one Offer per priced plan/pricingpricingOffersSchema(), from the same catalog; each Offer.url is /pricing#<plan id>, the anchor on that plan's card
FAQPage/pricingsite/lib/faqs.ts PRICING_FAQS — the same array the page renders
BreadcrumbList/features, /about, /pricingthe trail passed by each page

The FAQPage markup and the visible FAQ section read from one array on purpose: Google treats FAQ markup whose answers are not visible on the page as a violation, so they must not be allowed to drift. The pricing FAQs carry no numbers at all — every amount a reader sees is derived from site/lib/pricing at render time, and a price typed into an answer would be a second copy.

Prices are single-sourced, and that is the rule that replaced the old ban on offers. There are real published prices now (decision D24), so /pricing emits an Offer per priced plan and / emits the AggregateOffer range. Every one of them — the price, the currency and the billing unit — is generated from site/lib/pricing (plans.catalog.json, the vendored copy of inference/src/ReelBolt.Inference.Api/Billing/plans.catalog.json that CI diffs and fails on), which is also what the plan cards and the comparison table render from. Never hand-write a price in markup or in copy: a literal in a JSON-LD blob, a $29 typed into a sentence, or a hardcoded top-up rate is a second source of truth that Google will eventually index after the catalog has moved on. A plan with no published price (Enterprise) gets no Offer at all rather than a guessed one — decision D7 keeps it "Contact us" only. aggregateRating remains banned outright: still no real reviews, and fabricated review markup is a manual-action-level violation rather than a growth tactic.

CookieConsentProvider (site/components/consent/CookieConsentProvider.tsx) tracks one of three states — no choice yet, granted, denied — in localStorage. GoogleAnalytics (site/components/analytics/GoogleAnalytics.tsx) will not inject the gtag.js script tag, call gtag(), or set any GA cookie unless that state is exactly 'granted'. This is deliberate: no script loads before a choice is made, and none loads at all if the visitor rejects or simply never interacts with the banner (CookieBanner, fixed to the viewport bottom until a choice is made).

The direct consequence: GA4 will undercount total visits by whatever fraction of visitors reject the banner or leave before answering it. This is not a bug to "fix" by loading analytics earlier or by treating silence as consent — it is the privacy tradeoff the consent-gating design exists to make, and any pageview number pulled from GA4 should be read as a floor, not a total.

Audited from the source, not from the design's intention. Three things exist, and only one of them is a cookie:

WhatSet byWhenWhy
localStorage['rf-cookie-consent'] ('granted' / 'denied')CookieConsentProvider, key in site/lib/consent.tsonly when the visitor clicks Accept or Reject; removed again by the footer's "Cookie preferences"it is the choice — first-party, no identifier, never sent anywhere, so it needs no consent of its own
_ga, _ga_<container>Google Analytics, injected by GoogleAnalyticsonly while consent is 'granted'page-view measurement
— nothing else——app/api/contact/route.ts and functions/api/contact.ts set no cookie and keep no session; the rest of the site is static

No checkout script runs on /site, or on /app either — checked, not assumed. grep -rniE "paddle|stripe" site/ prints nothing, no cdn.paddle…/js.stripe… appears anywhere in the repository, and /web's billing page does not host a checkout: it calls the API, gets a checkoutUrl, and redirects the browser to the provider's own hosted page (web/app/(app)/settings/billing/page.tsx). So the banner needs no new cookie category, and H6's "flag it to B" resolves to no action: /web needs no consent banner, because the payment provider's script never loads in either app. The only cookies on /app are the auth cookies the Go API sets — reelbolt_token (httpOnly, nginx) and reelbolt_user (readable) — which are strictly necessary for a session the visitor asked for by signing in, and are exempt on that basis. Worth re-checking if a provider ever moves to an embedded checkout (Paddle.js / Stripe.js inline), which is the one change that would put a third-party script on /app.

The gate covers both directions, and it took two fixes to be true. GoogleAnalytics injects nothing unless consent is 'granted'; lib/gtag.ts's trackEvent — the only way this site sends an event — re-reads the stored choice at the moment of the call, rather than trusting that window.gtag exists. That second check is what closes the "accept, then reopen the banner from the footer and reject" path: the already-injected gtag.js is not unloaded by a React re-render, so on the first check alone a visitor who had just withdrawn consent would have kept sending events. Rejecting also arms Google's own opt-out flag for the property (window['ga-disable-<ID>'] = true), which stops the loaded library's own beacons. It is set only on an explicit rejection, never while the visitor is undecided, because arming it before the acceptance that renders the tags could win the race and silently collect nothing.

What the gate still does not do. A visitor who rejects after accepting keeps the loaded gtag.js in memory for the rest of that page view, and the _ga cookies already written are not deleted — they expire on Google's schedule. ga-disable stops further collection, which is the part that matters, but a withdrawal that also erases what was already stored needs either a reload or Google Consent Mode (which sends cookieless pings and is a different privacy posture from "no script before a choice"), and neither is built. The banner's copy stays true: nothing new is set after a rejection.

No invented facts​

Two categories of content went into this site, and they were held to different rules:

  • Product capabilities (what ReelBolt does, its architecture, its workflow pipeline) were written from CLAUDE.md and the actual codebase — real, verifiable facts about a real system.
  • Business/legal facts that only the business owner can supply — legal entity name, registered address, registration/VAT numbers, DPO contact, governing law, liability caps, retention periods, response-time SLAs — were never invented, guessed, or filled with a plausible-looking placeholder. Every one of them is a literal [BRACKETED_TOKEN] in site/lib/legal-placeholders.ts, imported everywhere it's needed (footer, contact page, privacy policy, terms of service) so there is exactly one place to fill in real values, and no page silently shipped with a fabricated address or a made-up company name that could be mistaken for real. See the launch checklist above for the full list of tokens to replace before launch.
  • The same discipline extended to the visual redesign: the homepage strip (site/components/home/UseCaseStrip.tsx) lists the video jobs ReelBolt is built to produce ("Product launches", "Feature demos", …) as plain text, never captioned as "partners" or "customers" — no partner logo or customer name was invented. No fabricated social-media links were added either; the reference design's social-icon rail became real in-page section anchors. See docs/site-design-system.md.
  • The copy rewrite kept the same rule. Every capability claim on the marketing pages maps to a real one in CLAUDE.md — automatic silence/bad-take removal, best-take selection, overlays and tracked screen inserts, colour grading, music and sound effects, the scoring review loop, bring-your-own provider, self-hosting — and the 2026-10 refresh added only what docs/*.md on master describe as shipped: text-to-speech narration (docs/voiceover.md, phase 1), planned b-roll under the budget gate (docs/video-generation.md, phases 1–2), object tracking and censoring (docs/video-editing.md), confidence-gated decision models plus the calibration view (docs/decision-models.md), the read-only studio timeline view (docs/editor.md — "no editor UI ships"), the platform assistant (docs/platform-assistant-mcp.md), semantic file search (docs/embeddings.md) and the public /docs/ site (docs/docs-site.md). The homepage FAQ and llms.txt grew by the same facts and no others. What changed is the vocabulary, not the claim. No customer count, rating or time saving was asserted, and the structured data carries no aggregateRating for the same reason (see site/lib/structured-data.ts). Pricing is asserted now, but only from the one catalog the billing service also reads — every price on /, /pricing and in their Offer markup comes from site/lib/pricing, with no figure typed into a page, a component or a FAQ answer. The H8 (saas-launch) refresh added the same discipline to the new capabilities: team workspaces are described only as shipped (shared projects and run history, roles, invitations, a shared credit pool — docs/multi-tenancy.md, docs/user-guide/ workspaces.md), bring-your-own provider carries its plan gate rather than an unconditional promise, and the desktop runner is described as being built — no download is claimed while site/lib/runner-downloads is empty, and the runner-only preference is described as failing rather than falling back to paid capacity, because that is the engine's actual behaviour (docs/compute-fabric.md). The compute fabric defaults to local and self-hosting is unchanged, so nothing on the site promises the cloud pool.