The whole marketing site — home, pricing, how-it-works, integrations, privacy, terms, changelog — is a swappable theme. Operators add a new theme by dropping a folder; switching is one click in Settings → System → Marketing.
Every marketing controller looks up its Inertia component through
App\Support\MarketingTheme::component($page) instead of
hardcoding a string. The resolver reads
app_settings.marketing_theme, falls back to
harvest (the built-in theme) when the column is empty or
points at a slug that isn't installed, and returns either:
harvest
(welcome, marketing/pricing,
marketing/how-it-works, etc.) — so existing installs
keep rendering the original layout.marketing-themes/{slug}/{page} for any other theme —
points Inertia at a self-contained per-theme folder.Create a folder under
resources/js/pages/marketing-themes/ named after the
theme's slug (kebab-case, no spaces). For example,
resources/js/pages/marketing-themes/meadow/.
Add a theme.json manifest at the root of that
folder. Minimum shape:
{
"name": "Meadow",
"description": "Calm, editorial layout with green accents."
}
Themes without a theme.json are ignored — the
manifest is what makes a folder a theme.
If a theme only ships a subset of the seven pages, add a
pages whitelist so the resolver knows which keys
the theme provides — every other page silently falls back to
the Harvest legacy component:
{
"name": "Aurora",
"description": "Editorial brutalist — home page only.",
"pages": ["home"]
}
Omitting pages means the theme is assumed to
provide all seven; useful when you're shipping a full bundle.
Drop the seven page components inside the folder, each receiving the same props the matching controller passes today. Names must match exactly:
home.tsxpricing.tsxhow-it-works.tsxintegrations.tsxprivacy.tsxterms.tsxchangelog.tsxUse resources/js/layouts/marketing-shell.tsx as
the shared shell, or ship your own per-theme shell inside the
folder.
Run npm run build (or keep npm run dev
running while you iterate) so Vite picks up the new files.
Open Settings → System → Marketing,
pick the new theme from the dropdown, and save. Visit
/, /pricing, etc. — they now render
from your folder.
The controllers pass the same props regardless of theme. Build your page components against this shape and a theme swap is a pure visual change:
| Page | Notable props |
|---|---|
home | canRegister, demoAgentId, content, seo |
pricing | plans, lifetime_plans, currency, currencies, matrix, faqs, shell, brand, seo |
how-it-works | steps, latency, shell, brand, seo |
integrations | native, data_sources, roadmap, shell, brand, seo |
privacy | content, shell, brand, seo |
terms | intro, sections, effective_date, contact_email, shell, brand, seo |
changelog | entries, shell, brand, seo |
The shipped theme is called harvest. For back-compat its
files live where they always did
(resources/js/pages/welcome.tsx +
resources/js/pages/marketing/*.tsx) rather than under
marketing-themes/harvest/. The resolver maps to those
legacy paths so existing installs upgrade with no rendering
difference.
Two additional themes ship in the box. Both are full bundles covering all seven pages and use the live demo agent for the hero chat preview.
aurora) — editorial
brutalist with a paper/ink palette and an electric-lime signal
accent. Lives at
resources/js/pages/marketing-themes/aurora/.
Ships auth-shell.tsx so login, register, and
password-reset flows render in the same paper/ink/lime palette
as the marketing site.
prism) — purple/coral
gradient identity, Inter Tight body with Instrument Serif
italic accents, glossy hero mockup with floating context cards,
gradient-bar footer. Lives at
resources/js/pages/marketing-themes/prism/. Also
ships auth-shell.tsx for theme-matched sign-in.
Prism's hero ships an interactive Try it form. A visitor
pastes a URL, the server fetches the page synchronously
(POST /api/v1/widget/try-now), extracts readable text
via HtmlExtractor, chunks it, and stashes the chunks
under a short-lived cache token (1h TTL). The hero chat then
switches to that cached context — every visitor message routes
through POST /api/v1/widget/try-now/stream, which
streams an LLM reply grounded in the cached chunks via
<source> tags.
Lives at app/Services/TryNow/TryNowSession.php and
app/Http/Controllers/Widget/TryNowController.php. No
agent, no workspace, no DB writes — it can't pollute tenant data.
Rate-limited per IP via the try-now-start and
try-now-stream limiters defined in
AppServiceProvider::configureRateLimiting.
Both Aurora and Prism ship an auth-shell.tsx alongside
their seven marketing pages. The dispatcher at
resources/js/layouts/auth-layout.tsx picks the right
shell based on marketingTheme (the shared Inertia
prop). When the active theme doesn't ship a shell, the dispatcher
falls back to the default Harvest two-panel layout. To add a new
theme's auth shell:
resources/js/pages/marketing-themes/<slug>/auth-shell.tsx exporting a component with { title, description, children } props.auth-layout.tsx: if (marketingTheme === '<slug>').tests/Feature/Marketing/ that hits /login and /register with the theme active.Flip between them under Settings → System → Marketing, or via tinker:
php artisan tinker --execute 'App\Models\AppSetting::singleton()->forceFill(["marketing_theme" => "prism"])->save();'
If a theme's folder is deleted, its manifest becomes invalid, or the
slug stored in app_settings.marketing_theme doesn't match
any installed theme, the resolver silently falls back to
harvest. The marketing site can't be blanked by a stale
setting. To reset explicitly, run:
php artisan tinker --execute 'App\Models\AppSetting::singleton()->forceFill(["marketing_theme" => "harvest"])->save();'