Repository navigation
Themes
How the public site's look and markup are controlled in AstroBaaS: the three ways to ship a theme, design tokens, template slots, child themes, the customizer, and the Content Security Policy rules theme authors must follow. Written for developers and designers building or adapting a theme; operators who only want to switch or recolour a theme can skip to The customizer.
AstroBaaS draws one line through all extensions: data installs at runtime, code is compiled in. Astro compiles the server at build time, so anything that has to execute or render as a component must be present at build time. What can be expressed as data (colours, type, a stylesheet, section patterns) can be installed on a running site.
For themes this gives three delivery routes:
Declarative (theme.json) |
External (ASTROBAAS_THEMES) |
Bundled (defineTheme) |
|
|---|---|---|---|
| How it is installed | Paste JSON into Admin → Themes, or POST /api/themes/install. No rebuild. |
npm install the package, name it in ASTROBAAS_THEMES, restart. |
Add src/themes/<id>/, add one import to src/themes/index.ts, rebuild and redeploy. |
| Design tokens | Yes | Yes | Yes |
Stylesheet (css) |
Yes | Yes | Yes |
| Section patterns | Yes | Yes | Yes |
| Replace template slots (components) | No | Yes | Yes |
| Runs code | Never | Yes, operator-installed | Yes, compiled in |
Start declarative. It covers colour, typography, shape, a stylesheet and ready-made page layouts, which is most of what a theme is. Move to an external or bundled theme only when you need to change page structure, which needs real .astro components.
There is no way to upload template code through the browser. Markup is code, and code arrives either compiled into the build or placed on the server by the operator. See the extensibility model in the README.
Themes present content; they do not:
- add routes or pages (use a plugin or a custom content type instead),
- change the admin,
- ship server logic (that is what plugins are for).
Three themes ship in src/themes/:
| Id | What it is | Slots overridden |
|---|---|---|
default |
The stock site. Overrides nothing; the reference for what each default slot renders. | none |
editorial |
A serif magazine look. The reference for a template theme. |
Header, Footer, Home, PostCard, PostArticle, PageArticle, Breadcrumbs
|
marquee |
A street-poster look: display caps, heavy rules, one loud accent. | the same seven |
Both editorial and marquee inherit Sidebar and TableOfContents from the defaults. The theme card in Admin → Themes lists exactly which slots a theme overrides; trust that over this table if they ever differ.
A declarative theme is a JSON manifest. It is validated, stored in the database and interpreted by existing code. Nothing is executed.
{
"id": "sunset",
"name": "Sunset",
"version": "1.0.0",
"author": "You",
"astrobaasApi": "^1.0.0",
"tokens": {
"colors": { "primary": "#e2571e", "secondary": "#8b1e3f" },
"style": { "radius": "lg", "density": "roomy" },
"colorScheme": "auto"
},
"css": ".ab-hero { background: linear-gradient(var(--primary-color), var(--secondary-color)); }",
"screenshot": "/uploads/sunset-preview.png",
"patterns": [
{
"name": "splash",
"label": "Splash",
"description": "Full-bleed hero.",
"html": "<div class=\"ab-hero ab-align-center\"><h2>Headline</h2></div>"
}
]
}| Field | Required | Notes |
|---|---|---|
id |
yes | Kebab-case. May not reuse a bundled theme id. |
name |
yes | Display name. |
version |
yes | Semver. |
description, author
|
no | Shown in the admin. |
homepage |
no |
https only. |
astrobaasApi |
no | Semver range of the theme manifest API. The host implements 1.0.0. |
tokens |
no | Partial design tokens: colors, typography, style, colorScheme. Anything omitted falls back to the stock defaults. |
css |
no | Stylesheet appended to /theme.css. Up to 100 KB. |
patterns |
no | Up to 24 section patterns, each up to 8,000 characters of HTML. |
screenshot |
no | Preview for the theme card. |
All of these are enforced at install time, not discovered on a live site:
-
Colours must be hex (
#1a2b3c). Token values are written into/theme.cssas custom properties, so a value carrying;or}could otherwise escape into arbitrary CSS. -
stylevalues must be token keys, not CSS."radius": "lg"is accepted;"radius": "14px"is refused. Unknown scale names are refused too. - Every pattern must survive the content sanitizer byte-for-byte. A pattern that does not is refused, and the error response shows your markup next to what the sanitizer returned.
-
cssis filtered but not namespace-scoped.@import,</style>escapes andjavascript:URLs are removed. Anything over 100 KB is refused whole rather than truncated. -
screenshotmust be adata:image URI (png, jpeg, webp or svg+xml) or a root-relative path, under 200 KB. -
tokens.customCSSis refused. Custom CSS belongs to the operator; a theme usescss.
Unlike a plugin, a theme's CSS may restyle the whole site, because that is its job. It cannot run code, read data or make requests, but it does control what visitors see. Install themes only from sources you would accept a stylesheet from.
In the admin, open Admin → Themes, paste the manifest into the install box and install. Over HTTP (admin session required):
POST /api/themes/install
Content-Type: application/json
{ "manifest": { "id": "sunset", "name": "Sunset", "version": "1.0.0" } }DELETE /api/themes/install
Content-Type: application/json
{ "id": "sunset" }Behaviour to know:
-
Installing does not activate. Activate it from the theme list, or with
POST /api/themes/activateand{ "id": "sunset" }. - Upgrading keeps the operator's customizations. The manifest's tokens are the theme's defaults and only seed settings on first install.
- Uninstalling an active theme is refused. Activate another theme first.
- Bundled themes cannot be uninstalled through the API; remove the import and rebuild instead.
ASTROBAAS_THEMES is a comma-separated list of module specifiers, loaded at boot. It mirrors ASTROBAAS_PLUGINS (see Plugins). Each module default-exports one of:
- a theme object,
- an array of themes,
- a factory
({ defineTheme, apiVersion }) => theme | theme[].
Prefer the factory. An external module cannot resolve astrobaas/core in a deployed install, so the host passes in its own defineTheme.
// @acme/theme-harbour/dist/index.mjs
export default function create({ defineTheme, apiVersion }) {
return defineTheme({
id: 'harbour',
name: 'Harbour',
description: 'Navy and brass.',
version: '1.0.0',
author: 'Acme',
requiresCore: '^1.0.0', // refused at load if the host API does not match
settings: {
colors: { primary: '#0b2545', secondary: '#13315c', accent: '#b08d57',
background: '#ffffff', text: '#0b2545' },
typography: { headingFont: 'Lora', bodyFont: 'Inter', fontSize: '17px' },
},
css: '.ab-hero { border-bottom: 4px solid var(--accent-color); }',
});
}npm install @acme/theme-harbour
ASTROBAAS_THEMES=@acme/theme-harbour npm startapiVersion is the host's API version (currently 1.0.0). A theme that declares an incompatible requiresCore range is refused at load with a message naming both versions. Two packages claiming the same id are refused too.
An external theme may also carry components, while a declarative manifest may not. That is intentional: a manifest is data an admin installs through the browser, whereas a module named in the environment is code the operator deliberately put on the server.
A theme that fails to load is logged with its specifier and reason, the site renders with the stock look, and boot continues. A module may not claim a bundled id, so a package exporting a theme called default cannot replace the stock look. Source: src/themes/external.ts.
src/themes/<your-id>/
index.ts # defineTheme({...}), required
Header.astro # optional slot overrides
PostCard.astro
defineTheme is an identity helper exported from astrobaas/core that gives you type checking. Register the theme in src/themes/index.ts with one import and one entry in BUNDLED_THEMES, then rebuild. It appears in Admin → Themes, ready to activate.
defineTheme accepts:
| Field | Notes |
|---|---|
id |
Stable, kebab-case, unique. Links the module to its database row. |
name, description, version, author
|
Shown in the admin. |
settings |
Default design tokens. Complete for a root theme; partial or omitted for a child theme. |
components |
Slot overrides. Omit for a tokens-only theme. |
extends |
Parent theme id, for a child theme. |
css |
Stylesheet appended to /theme.css after the token declarations and before the operator's custom CSS. 100 KB limit. |
patterns |
Section patterns offered in the editor. |
screenshot |
data: image URI or root-relative path. |
A theme may replace any subset of a fixed set of components. Every slot you do not declare falls back to the built-in component, so a theme keeps working when new slots are added.
| Slot | Renders | Main props |
|---|---|---|
Header |
Site header on every public page |
siteTitle, locale, localeOptions? (language-switcher entries, only set on record routes) |
Footer |
Site footer on every public page |
siteTitle, social?, locale
|
PostCard |
One post in a listing |
post (with author, category, image, date, read time resolved) |
PostArticle |
The single-post view |
post, contentHtml, author, category, date, readTime
|
Sidebar |
Optional aside; default renders nothing |
context ('archive' | 'post' | 'page' | 'home'), locale
|
Home |
The stock front page, shown while no CMS Page is set as home |
siteTitle, siteTagline, posts, locale
|
PageArticle |
A CMS Page at /{slug}, and at / when designated home |
post, contentHtml, isHome
|
Breadcrumbs |
The trail above content |
items ({ name, href? }[]), locale?
|
TableOfContents |
Contents list for the article |
items ({ id, text, level, depth }[]), label?, class?
|
Prop types live in src/core/theme-slots.ts and are exported from astrobaas/core (HeaderProps, PostCardProps, TableOfContentsProps, and so on). ThemeSlotProps['<Slot>'] always resolves to a slot's props.
Slot rules:
- Do not fetch in a slot. The route has already resolved the data; extra queries multiply per post.
-
contentHtmlis already sanitized. Render it withset:htmland do not re-sanitize or unescape it. It is the only value you should pass toset:html. -
Breadcrumbs: render nothing below two items, and mark the last item
aria-current="page". The same array feeds the page'sBreadcrumbListstructured data. -
Footer
socialhas six keys (twitter,github,linkedin,facebook,instagram,youtube), mapped from thesocial_*settings.tests/theme-slots.test.mjsfails if a theme reads a key outside that contract.
A theme's settings are its defaults. Operators override them in the customizer and their values win, so read values from CSS custom properties instead of hard-coding them:
.my-card a { color: var(--primary-color); }
.my-card { font-family: var(--body-font), sans-serif; }All tokens are served from /theme.css, an external stylesheet, so the strict CSP applies unchanged.
| Group | Custom properties |
|---|---|
| Colour |
--primary-color, --secondary-color, --accent-color, --background-color, --text-color, --surface-color, --muted-color, --border-color, --on-primary, --success-color, --warning-color, --danger-color
|
| Typography |
--heading-font, --body-font, --font-size-base, --letter-spacing, --line-height, --heading-line-height, --heading-weight, --h1, --h2, --h3
|
| Shape and space |
--radius-sm, --radius-md, --radius-lg, --radius-pill, --space-1 to --space-8, --section-y, --shadow-sm, --shadow-md, --shadow-lg
|
| Layout |
--container, --measure, --btn-bg, --btn-fg, --btn-border, --btn-radius, --header-align, --header-direction, --header-pad, --header-border
|
--on-primary is derived from the primary colour's luminance when not set, so text on a brand-coloured button stays readable. Chosen font families are fetched as webfonts by the layout; do not assume they are installed locally.
Shape, spacing and layout values are keys, not CSS. The key selects a pre-authored block of declarations in src/lib/theme-tokens.ts:
| Scale | Keys | Default |
|---|---|---|
radius |
none, sm, md, lg, full
|
md |
density |
compact, normal, roomy
|
normal |
shadow |
none, soft, strong
|
soft |
containerWidth |
narrow, normal, wide, full
|
normal |
typeScale |
compact, normal, spacious
|
normal |
headingWeight |
normal, medium, semibold, bold
|
bold |
buttonStyle |
solid, outline, soft, pill
|
solid |
headerStyle |
minimal, centered, split, masthead
|
split |
An unrecognised key falls back to the scale's default. Every key in a scale emits the same token names, so you can rely on a token existing whichever key the operator chose.
There is no --ab-* token namespace. The ab- prefix belongs to section classes (.ab-hero), never to custom properties; var(--ab-radius-md) silently resolves to nothing.
colorScheme is 'light', 'dark' or 'auto'. Under auto, /theme.css emits a prefers-color-scheme block and an explicit [data-theme] override, so a visitor's toggle wins in both directions. The toggle is stored in a cookie that the server reads, stamping data-theme on <html> before the page is sent: no flash of the wrong scheme and no inline script.
A bundled or external theme may set settings.darkColors (primary, secondary, accent, background, surface, text, muted, border). Anything left out is derived from the light palette.
Editors build layouts from sections (hero, columns, card, CTA, note, media with text, gallery, spacer, table, video link). The editor inserts them as allow-listed classes on ordinary tags, for example <div class="ab-hero ab-align-center">. There is no block tree: post content remains one sanitized HTML string, so sections arrive inside contentHtml with nothing special to render.
The default section styles in src/styles/sections.css are written entirely in tokens, so changing a preset restyles every section. A theme can restyle sections from its css:
.ab-hero { padding-block: 6rem; text-align: start; }
.ab-columns { gap: var(--space-6); }Two constraints:
- Never use a Tailwind utility in section CSS or stored HTML. Tailwind generates classes by scanning source files, and database content is not scanned.
-
Scope parts to their parent (
.ab-columns > .ab-col, not.ab-col), so an orphaned part degrades to readable content.
A theme can restyle a section but not restructure it. The vocabulary is defined in src/core/sections.ts.
Patterns are named arrangements of sections offered in the editor alongside the built-in ones:
patterns: [{
name: 'split-hero',
label: 'Split hero',
description: 'Headline beside an image.',
html: '<div class="ab-media ab-media-right">…</div>',
}]For bundled and external themes, a pattern that does not survive the sanitizer byte-for-byte is dropped with the reason shown in Admin → Tools. Declarative themes are refused at install instead. Pattern names are namespaced as <themeId>--<name>, so a theme cannot shadow a built-in pattern.
A child theme declares a parent and overrides only what it needs. Everything else (components, tokens, CSS, patterns) comes from the parent, so later fixes to the parent still reach the child.
import { defineTheme } from 'astrobaas/core';
import Footer from './Footer.astro';
export default defineTheme({
id: 'marquee-acme',
name: 'Marquee (Acme)',
description: 'Marquee in Acme blue with our own footer.',
version: '1.0.0',
author: 'Acme',
extends: 'marquee',
settings: { colors: { primary: '#005b8f' } }, // partial, or omit entirely
components: { Footer }, // Marquee's other slots are kept
});A root theme must supply a complete settings palette; a child's settings is a deep partial and may be omitted. The TypeScript type enforces that difference.
Merge rules, from src/lib/theme-inherit.ts:
| What | Rule |
|---|---|
| Components | Child wins per slot. |
| Tokens | Child wins per leaf. Setting colors.primary keeps every other colour. |
| CSS | Parent first, then child, so child rules win by cascade order. |
| Patterns | Parent's, then child's; the child wins on a duplicate name. |
| Blank values |
'', null and undefined mean "inherit". false and 0 are real values. |
A missing parent or a cycle resolves to the child plus whatever ancestors were reached, with a log message. The admin marks a child whose parent is not installed.
src/themes/presets.ts holds complete named bundles (colours, type, shape, density, dark palette) shown as one-click options in the customizer: clean, editorial, optical, boutique, midnight, bold, soft and terminal. Applying a preset fills the controls; saving is still an explicit step. Presets post the same payload as the manual controls, so there is one write path and one validator.
Admin → Themes has three parts: an install box for manifests, the list of installed themes (bundled, external and declarative) with activate and uninstall buttons, and a customizer for the active theme.
The customizer covers:
- colours, including the semantic colours,
- typography (heading font, body font, base size, type scale, heading weight),
- shape and layout scales (radius, density, shadow, container width, button style, header style),
- colour scheme: light, dark or auto,
- presets,
- custom CSS, appended last to
/theme.cssso it overrides the theme.
Customizing, activating and installing themes all require an admin account. The operator's values always override the theme's defaults, and they survive theme upgrades.
Production pages are served with a hash-based Content Security Policy with no 'unsafe-inline' and no 'unsafe-hashes'. The build hashes the scripts and styles it bundles, and the browser refuses everything else. Pages often look correct in astro dev (which has no CSP) and lose styling in production, so follow these rules from the start:
| Do | Do not |
|---|---|
Use classes, including the shared utilities ab-ink, ab-muted, ab-accent, ab-on-accent, ab-surface, ab-bg-accent, ab-border-ink, ab-heading-font. |
Use style="..." attributes. Hashes never cover style attributes, so the browser drops them silently. |
Use a scoped <style> block inside your .astro component. Astro hashes it. |
Emit an inline <style> or <script> at runtime. |
Put global rules in the theme's css, served from /theme.css. |
Rely on @import in theme CSS; it is stripped. |
Bundle scripts through Vite (import in a component script). |
Load scripts from a CDN without configuring the CSP. |
To load assets from other origins, opt them in per directive through environment variables (see Configuration and Security):
CSP_SCRIPT_SRC=https://cdn.jsdelivr.net
CSP_STYLE_SRC=https://cdn.jsdelivr.net
CSP_FONT_SRC=https://fonts.gstatic.com
CSP_IMG_SRC=https://cdn.example.com
CSP_CONNECT_SRC=https://api.example.com
CSP_ALLOW_WASM=1 # allow WebAssembly (e.g. 3D decoders)These are read when the site is built, so rebuild after changing them. While tuning, the browser console names each blocked request and the directive that blocked it. worker-src 'self' blob: is on by default. Bundling a library through npm and Vite needs no CSP change at all, which is the simplest option.
Visual effects (parallax, scroll reveals, WebGL) belong in the frontend layer, not in post content: the content sanitizer never lets <canvas> or <script> into stored HTML. The pattern is:
- read content server-side in an
.astropage throughastrobaas/coreor the REST API, - render the structure as plain HTML,
- add effects with CSS and small bundled scripts, or with an Astro island (
@astrojs/reactis already installed) hydrated withclient:visible, - pass CMS data such as the active theme colour into the effect as props.
---
import { LocalDB } from 'astrobaas/core';
import Scene from '../components/Scene'; // a React Three Fiber island
const theme = await LocalDB.getActiveTheme();
---
<Scene client:visible color={theme?.settings.colors.primary} />Always honour prefers-reduced-motion, prefer client:visible over client:load, and cap the device pixel ratio for WebGL. A working reference is the /showcase page (src/pages/showcase.astro). There is no visual page builder for effects; they are composed in code. Details: FRONTEND_EFFECTS.md.
Scaffold it with the CLI, which refuses to overwrite existing files:
npx astrobaas theme new harbourThis writes src/themes/harbour/index.ts and src/themes/harbour/Header.astro. A trimmed version:
// src/themes/harbour/index.ts
import { defineTheme } from 'astrobaas/core';
import Header from './Header.astro';
export default defineTheme({
id: 'harbour',
name: 'Harbour',
description: 'Navy and brass, centred header.',
version: '1.0.0',
author: 'You',
settings: {
colors: { primary: '#0b2545', secondary: '#13315c', accent: '#b08d57',
background: '#ffffff', text: '#0b2545' },
typography: { headingFont: 'Lora', bodyFont: 'Inter', fontSize: '17px' },
style: { headerStyle: 'centered', radius: 'sm' },
colorScheme: 'auto',
},
css: `.harbour-tagline { color: var(--muted-color); }`,
components: { Header },
});---
// src/themes/harbour/Header.astro
import type { HeaderProps } from 'astrobaas/core';
const { siteTitle = 'AstroBaaS' } = Astro.props as HeaderProps;
---
<header class="harbour-header">
<a href="/" class="ab-heading-font ab-ink">{siteTitle}</a>
<nav>
<a href="/blog">Blog</a>
<a href="/about">About</a>
</nav>
</header>
<style>
.harbour-header {
display: flex;
flex-direction: var(--header-direction);
align-items: center;
padding-block: var(--header-pad);
border-bottom: var(--header-border) solid var(--border-color);
}
.harbour-header a { color: var(--primary-color); }
</style>Register it:
// src/themes/index.ts
import harbourTheme from './harbour';
export const BUNDLED_THEMES: ThemeDefinition[] = [defaultTheme, editorialTheme, marqueeTheme, harbourTheme];Add to the existing array; do not replace it. Then rebuild, open Admin → Themes, and activate Harbour.
npm run build # astro check type-checks your slots against the prop contracts, then builds
npm run dev # activate the theme in Admin → Themes and click through the site
npm test # unit tests plus the smoke suite (which starts its own dev server)Theme-specific unit tests you can run on their own:
| Command | Checks |
|---|---|
node tests/theme-slots.test.mjs |
Slot contracts, including the footer social keys. |
npm run test:tokens |
Every scale key emits the same tokens, and every token has a consumer. |
npm run test:theme-manifest |
Declarative manifest validation. |
node tests/theme-distribution.test.mjs |
External theme loading and child-theme merge rules. |
For a declarative theme, the install response is the test: it lists every validation error and, for a rejected pattern, shows the sanitizer's output next to yours. Check the theme card afterwards to confirm which slots are overridden, then view the site with the production CSP (npm run build and run the built server) before shipping.
A theme is not the only way to control how a site looks. You can build a separate frontend, for example a static Astro site, a Next.js app or a mobile app, that reads content from AstroBaaS over its REST API and renders it however you like. AstroBaaS then serves as the backend and admin, and your frontend owns all of the markup, routing and styling, with none of the slot or CSP constraints above applying to your separate deployment.
Choose this when you need routes or page types a theme cannot add, want to host the frontend elsewhere, or already have a design system in another framework. Content types you mark visibility: 'public' are readable without credentials; anything else needs an API key. See Headless and REST API and SDK, CLI and MCP.
- Plugins: code and declarative plugins, including plugin sections and CSS
- Custom content types
- Headless and REST API
- Security: the CSP and the content sanitizer
-
Configuration:
ASTROBAAS_THEMESand theCSP_*variables - Admin tour
- Architecture
- THEME_DEVELOPMENT.md
- FRONTEND_EFFECTS.md
AstroBaaS is free software under GPL-3.0-or-later. This wiki describes the latest release; the repository and its CHANGELOG are the final word. Found a mistake here? Open an issue.
Getting started
Using the CMS
Building with it
Extending
Commerce and email
Operating
Project