Repository navigation
Authoring a theme
This page is for developers building Cactus themes. If you're looking to customise the appearance of your existing site, see Appearance and design instead.
This guide is everything you need to build and ship a working Cactus theme from scratch. If you follow it end to end, you'll have a theme that installs via the admin UI and can be activated without a redeploy.
Themes in Cactus v1 control the layout and structure for core pages:
- Info pages (
/<slug>) - The public homepage (
/) - The public 404 and 500 error pages
- The navigation header and footer
Module-rendered content gets a slot system in a future release. For now, module pages render inside the admin UI only and don't need theme integration.
Every theme repo must contain a cactus.theme.json at its root:
{
"name": "Midnight",
"version": "1.0.0",
"description": "A dark, high-contrast theme for Cactus Foundation.",
"author": "Jane Developer",
"cookieCategories": []
}| Field | Type | Description |
|---|---|---|
name |
string (required) |
Unique display name. Also used as the folder name (themes/<name>). |
version |
string (required) |
Semver, e.g. 1.0.0. Must match the latest tagged GitHub release. |
description |
string |
Short description shown in the admin Themes page. |
author |
string |
Author name or URL. |
cookieCategories |
string[] |
Non-essential cookie categories this theme uses. Omit or use [] if it sets no cookies. |
cactus-theme-midnight/
├── cactus.theme.json ← manifest (required)
├── components/
│ ├── Layout.tsx ← root layout component
│ ├── Nav.tsx ← navigation header
│ └── Footer.tsx ← footer
├── styles/
│ └── midnight.css ← theme CSS
└── README.md
The component names are a convention, not enforced by the framework. What matters is that the theme exports a layout that the core app can use. In the current implementation, the active theme's components/Layout.tsx is imported directly by the app's root layout.
Must accept and render children:
type LayoutProps = {
children: React.ReactNode
siteName?: string
privacyPolicySlug?: string | null
termsSlug?: string | null
}
export default function Layout({ children, siteName, privacyPolicySlug, termsSlug }: LayoutProps) {
return (
<div className="midnight-shell">
<Nav siteName={siteName ?? 'My Site'} />
<main>{children}</main>
<Footer siteName={siteName ?? 'My Site'} privacyPolicySlug={privacyPolicySlug} termsSlug={termsSlug} />
</div>
)
}type NavProps = { siteName: string }
export default function Nav({ siteName }: NavProps) { ... }type FooterProps = {
siteName: string
privacyPolicySlug?: string | null
termsSlug?: string | null
}
export default function Footer({ siteName, privacyPolicySlug, termsSlug }: FooterProps) { ... }Core CSS variables are defined in app/globals.css and available to theme CSS:
:root {
--color-bg: #ffffff;
--color-fg: #111827;
--color-muted: #6b7280;
--color-border: #e5e7eb;
--color-primary: #16a34a;
--color-primary-hover: #15803d;
--color-danger: #dc2626;
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
--radius: 6px;
}Override these in your theme CSS to restyle the whole site consistently.
- Clone your theme repo into the
themes/directory of a local Cactus install:git clone https://github.com/you/cactus-theme-midnight themes/midnight
- Create a
Themedatabase row pointing at it:# In a psql or Prisma Studio session: INSERT INTO "Theme" (id, name, version, "isActive", "installedAt") VALUES ('dev-midnight', 'midnight', '0.0.1', false, NOW());
- Activate it in the admin UI: Themes → Activate.
- Visit the public site to see your theme in action.
Changes to TSX/CSS files hot-reload with npm run dev.
- Tag a release on GitHub:
git tag v1.0.0 && git push --tags - Create a GitHub Release for the tag (required — the update flow only considers tagged releases, never raw branch heads).
When a Cactus site admin installs your theme:
- They enter your GitHub repo URL in Admin → Themes → Install a theme.
- Cactus fetches
cactus.theme.jsonfrom the repo'sHEAD. - It finds your latest tagged release and its commit SHA.
- It commits your repo as a git submodule at
themes/<name>in the main Cactus repo via the GitHub API. - Vercel deploys the commit. Your theme code is now available in the build.
After install, the admin can activate your theme immediately (no redeploy needed for activation).
- Commit and push changes to your theme repo.
- Tag a new release:
git tag v1.1.0 && git push --tags - Create a GitHub Release for it.
- The Cactus admin will see "Update available" on the Themes page. They click Update.
- Public repos only. The install flow fetches the manifest and submodule from GitHub without authentication (or with a read-only token). Private repos are not supported.
- No shelling out to git. The install works via the GitHub REST API. This is a Vercel platform constraint — serverless functions run on an ephemeral, read-only filesystem.
- Activating a theme is live and immediate, no preview-before-activating flow. Plan accordingly.
- The module-content slot system is not yet available. Module admin pages don't use the theme; public-facing module content will be integrated via slots in a future release.
Here's a complete minimal theme you can copy-paste as a starting point:
cactus.theme.json:
{
"name": "Minimal",
"version": "1.0.0",
"description": "A bare-bones Cactus theme for demonstration.",
"author": "You",
"cookieCategories": []
}components/Layout.tsx:
import Nav from './Nav'
import Footer from './Footer'
export default function Layout({
children,
siteName = 'My Site',
privacyPolicySlug,
termsSlug,
}: {
children: React.ReactNode
siteName?: string
privacyPolicySlug?: string | null
termsSlug?: string | null
}) {
return (
<>
<Nav siteName={siteName} />
<main style={{ maxWidth: 800, margin: '0 auto', padding: '2rem 1rem' }}>
{children}
</main>
<Footer siteName={siteName} privacyPolicySlug={privacyPolicySlug} termsSlug={termsSlug} />
</>
)
}components/Nav.tsx:
import Link from 'next/link'
export default function Nav({ siteName }: { siteName: string }) {
return (
<header style={{ borderBottom: '1px solid #e5e7eb', padding: '1rem' }}>
<Link href="/" style={{ fontWeight: 700, textDecoration: 'none', color: 'inherit' }}>
{siteName}
</Link>
</header>
)
}components/Footer.tsx:
import Link from 'next/link'
export default function Footer({
siteName,
privacyPolicySlug,
termsSlug,
}: {
siteName: string
privacyPolicySlug?: string | null
termsSlug?: string | null
}) {
return (
<footer style={{ borderTop: '1px solid #e5e7eb', padding: '1rem', fontSize: '0.875rem', color: '#6b7280' }}>
<span>© {new Date().getFullYear()} {siteName}</span>
{privacyPolicySlug && <Link href={`/${privacyPolicySlug}`} style={{ marginLeft: '1rem' }}>Privacy</Link>}
{termsSlug && <Link href={`/${termsSlug}`} style={{ marginLeft: '1rem' }}>Terms</Link>}
</footer>
)
}That's a complete, working theme. Tag it, create a GitHub Release, and it can be installed in any Cactus site.
Wiki: Home · Getting started · Running locally · Architecture overview · Authoring a module · Authoring a theme · Self-hosting and operations