Theme Development
A VeloCMS theme is a TypeScript package that exports three React components (BlogLayout, PostLayout, PageLayout) plus a theme.json manifest. That's the entire contract. You don't need to understand PocketBase, multi-tenant routing, or how ISR works — just build your layout components and wire them to the manifest. VeloCMS handles the rest.
The theme.json manifest
Every theme starts with a theme.json. This is what VeloCMS reads to understand a theme, validate compatibility, and generate the preview card. The full ThemeManifest interface lives in src/lib/themes/sdk/types.ts — here's a minimal example:
{
"$schema": "https://velocms.org/schemas/theme-manifest.json",
"name": "velocms-theme-aurora",
"displayName": "Aurora",
"version": "1.0.0",
"description": "A clean, distraction-free reading theme with subtle gradient accents.",
"author": {
"name": "Jane Dev",
"email": "[email protected]",
"url": "https://janedev.io"
},
"type": "layout",
"category": "personal",
"tags": ["minimal", "reading", "personal-blog"],
"engines": {
"velocms": ">=1.0.0"
},
"preview": {
"thumbnail": "./preview/thumbnail.png",
"screenshots": [
"./preview/blog-listing.png",
"./preview/post-detail.png"
]
},
"exports": {
"components": {
"BlogLayout": "./src/BlogLayout.tsx",
"PostLayout": "./src/PostLayout.tsx",
"PageLayout": "./src/PageLayout.tsx"
}
},
"pricing": {
"model": "free"
}
}Layout component contracts
Your three layout components receive typed props — VeloCMS passes posts, site settings, and member session data through these interfaces. You don't fetch anything yourself. The types come from src/components/themes/types.ts:
import type { BlogLayoutProps } from "@velocms/theme-sdk";
export default function BlogLayout({
posts,
settings,
siteUrl,
searchEnabled,
member,
currentPage,
totalPages,
}: BlogLayoutProps) {
return (
<main>
<h1>{settings?.site_name ?? "My Blog"}</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>
<a href={`${siteUrl}/blog/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
</main>
);
}import type { PostLayoutProps } from "@velocms/theme-sdk";
export default function PostLayout({
post,
settings,
siteUrl,
jsonLd,
member,
relatedPosts,
}: PostLayoutProps) {
return (
<article>
<h1>{post.title}</h1>
{/* jsonLd is a pre-built schema object — pass it to a <script> tag */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: jsonLdSafe(jsonLd) }}
/>
<div dangerouslySetInnerHTML={{ __html: post.content_html ?? "" }} />
</article>
);
}OKLCH token system
VeloCMS uses OKLCH for all color tokens. This isn't just aesthetic preference — OKLCH gives you perceptually uniform color scales, which means your brand color at 30% lightness is actually 30% as bright as your base, not the weird dark mess you get from HSL. Every theme has access to these CSS variables, defined in globals.css:
/* Core semantic tokens — always available in theme components */
:root {
--background: oklch(0.98 0 0);
--foreground: oklch(0.15 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.15 0 0);
--muted: oklch(0.96 0.005 240);
--muted-foreground: oklch(0.55 0.01 240);
--border: oklch(0.9 0.005 240);
--ring: oklch(0.55 0.16 264);
--primary: oklch(0.55 0.16 264);
--primary-foreground: oklch(0.98 0 0);
}
/* Dark mode — automatically applied via .dark class on <html> */
.dark {
--background: oklch(0.12 0.005 240);
--foreground: oklch(0.95 0 0);
/* ... */
}In your theme components, use Tailwind classes that reference these tokens: bg-background, text-foreground, border-border, and so on. Don't hardcode hex values — if you do, your theme won't respect the user's dark/light mode setting and it'll look broken in the preview.
Previewing a theme with DemoFixtures
Every theme is checked against real-looking data rather than an empty blog. DemoFixtures is a set of synthetic posts, site settings and member session data that a layout component renders against. The snippet below shows the shape of that call. It illustrates the contract, since the theme SDK that exports DemoFixtures isn't published yet:
import { DemoFixtures } from "@velocms/theme-sdk/testing";
import BlogLayout from "../src/BlogLayout";
// Render a layout against the synthetic fixture data.
export default function Preview() {
return (
<BlogLayout
posts={DemoFixtures.posts}
settings={DemoFixtures.settings}
siteUrl="https://demo.example.com"
searchEnabled={false}
member={null}
currentPage={1}
totalPages={1}
/>
);
}You can see the result for the built-in themes without installing anything. Each one has a public demo at /preview/themes/[slug] on velocms.org, which renders the theme's real layouts against DemoFixtures. The thumbnails in the theme gallery are generated from those same pages.
Third-party themes
There is no submission process today. The /developers pages don't accept themes, and there's no upload form, review queue, listing or revenue share behind them. When VeloCMS opens theme development to outside developers, the terms will be published here first. If you'd like to see a theme or integration on the platform, email [email protected].
Reference
theme.json manifest — all fields
| Field | Type | Required | Description |
|---|---|---|---|
| $schema | string | No | JSON Schema URL for editor validation |
| name | string | Yes | npm-style unique ID: velocms-theme-{name} |
| displayName | string | Yes | Human-readable name shown in the marketplace |
| version | string | Yes | Semantic version (e.g. 1.0.0) |
| description | string | Yes | Short description shown in marketplace card |
| author.name | string | Yes | Theme author name |
| author.email | string | Yes | Author contact email |
| author.url | string | No | Author website URL |
| type | "layout" | Yes | Always 'layout' for theme packages |
| category | "personal" | "magazine" | "business" | "portfolio" | "niche" | Yes | Marketplace category for filtering |
| tags | string[] | No | Searchable tags (e.g. minimal, dark, reading) |
| engines.velocms | string | Yes | Semver range of compatible VeloCMS versions |
| preview.thumbnail | string (path) | Yes | 600×400 PNG relative path. Required for marketplace listing. |
| preview.screenshots | string[] | Yes | 2+ full-page PNG paths at 1440×900 |
| exports.components.BlogLayout | string (path) | Yes | Relative path to BlogLayout component file |
| exports.components.PostLayout | string (path) | Yes | Relative path to PostLayout component file |
| exports.components.PageLayout | string (path) | Yes | Relative path to PageLayout component file |
| pricing.model | "free" | "one_time" | "subscription" | Yes | Revenue model. free = no revenue split. |
| pricing.price | number | No | Price in USD (cents). Required for paid themes. |
Layout component props
These are the exact TypeScript prop types VeloCMS passes to your three layout components. Design against them — they're the contract, not the examples in the guide sections above.
| Interface | Prop | Type | Optional | Description |
|---|---|---|---|---|
| BlogLayoutProps | posts | Post[] | No | |
| BlogLayoutProps | settings | SiteSettings | null | No | |
| BlogLayoutProps | siteUrl | string | No | |
| BlogLayoutProps | searchEnabled | boolean | No | |
| BlogLayoutProps | member | MemberSession | null | Yes | |
| BlogLayoutProps | currentPage | number | Yes | 1-indexed current page — defaults to 1 when no ?page= query param |
| BlogLayoutProps | totalPages | number | Yes | Total number of pages in the listing — 1 means no pagination |
| PostLayoutProps | post | Post | No | |
| PostLayoutProps | settings | SiteSettings | null | No | |
| PostLayoutProps | siteUrl | string | No | |
| PostLayoutProps | jsonLd | unknown | No | |
| PostLayoutProps | member | MemberSession | null | Yes | |
| PostLayoutProps | relatedPosts | RelatedPostCard[] | Yes | Optional related posts — rendered by themes that support it (Curator, Serif) |
| PageLayoutProps | settings | SiteSettings | null | No | |
| PageLayoutProps | member | { email: string; tier: string } | null | No | Minimal member shape — matches getMemberSession() return |
| PageLayoutProps | siteUrl | string | No | Site URL for absolute link building (JSON-LD, share CTAs) |
| PageLayoutProps | children | ReactNode | No | Rendered AST content goes here |
| HomeLayoutProps | posts | Post[] | No | First 6 published posts — fetched server-side in home/page.tsx |
| HomeLayoutProps | settings | SiteSettings | null | No | |
| HomeLayoutProps | member | { email: string; tier: string } | null | No | |
| HomeLayoutProps | siteUrl | string | No |
OKLCH token reference
| CSS variable | Default (light) | Default (dark) | Usage |
|---|---|---|---|
| --background | oklch(0.98 0 0) | oklch(0.12 0.005 240) | Page background |
| --foreground | oklch(0.15 0 0) | oklch(0.95 0 0) | Primary text |
| --card | oklch(1 0 0) | oklch(0.17 0.005 240) | Card / panel background |
| --card-foreground | oklch(0.15 0 0) | oklch(0.95 0 0) | Text on cards |
| --muted | oklch(0.96 0.005 240) | oklch(0.22 0.005 240) | Muted surface (code bg, table header) |
| --muted-foreground | oklch(0.55 0.01 240) | oklch(0.65 0.01 240) | Secondary text, placeholders |
| --border | oklch(0.9 0.005 240) | oklch(0.27 0.005 240) | Borders, dividers |
| --ring | oklch(0.55 0.16 264) | oklch(0.65 0.16 264) | Focus ring |
| --primary | oklch(0.55 0.16 264) | oklch(0.65 0.16 264) | Brand accent, links, buttons |
| --primary-foreground | oklch(0.98 0 0) | oklch(0.12 0 0) | Text on primary-colored surfaces |
| --destructive | oklch(0.55 0.2 25) | oklch(0.65 0.2 25) | Error states, delete actions |
| --accent | oklch(0.96 0.005 240) | oklch(0.22 0.005 240) | Hover highlights |
| --accent-foreground | oklch(0.15 0 0) | oklch(0.95 0 0) | Text on accent surfaces |
Tailwind classes mapped to tokens
| Tailwind class | CSS variable | Use for |
|---|---|---|
| bg-background | --background | Page body, outermost wrapper |
| text-foreground | --foreground | All body copy |
| bg-card | --card | Post cards, sidebar panels |
| text-card-foreground | --card-foreground | Text inside cards |
| bg-muted | --muted | Code blocks, table headers, callout backgrounds |
| text-muted-foreground | --muted-foreground | Bylines, timestamps, captions |
| border-border | --border | All borders and dividers |
| ring-ring | --ring | Focus outlines (accessibility) |
| bg-primary | --primary | CTA buttons, active nav items |
| text-primary | --primary | Links, accent text |
| text-primary-foreground | --primary-foreground | Text on primary-bg buttons |
| bg-destructive | --destructive | Error banners, delete buttons |
Theme quality checklist
| Requirement | How to verify |
|---|---|
| BlogLayout renders at 375px (mobile) | Chrome DevTools → iPhone SE viewport |
| PostLayout renders at 375px | Same as above |
| Dark mode works correctly | Toggle .dark class on <html> in DevTools |
| No hardcoded hex/rgb/hsl colors | grep -r '#[0-9a-f]\\{3,6\\}\\|rgb\\|hsl' src/ |
| <h1> is present exactly once in PostLayout | Chrome → Inspect → find h1 |
| All images use next/image (or <img> with explicit w/h) | grep -r '<img ' src/ (expect 0) |
| JSON-LD script tag present in PostLayout | View page source → search for application/ld+json |
| thumbnail.png is exactly 600×400 | file thumbnail.png → dimensions check |
| At least 2 screenshots at 1440×900 | file *.png → dimensions check |
Built-in theme implementations
| Theme slug | Best for | Start here for… |
|---|---|---|
| terminal | Developer blogs, changelogs | Simplest possible theme — 200 lines per layout |
| default | Personal blogs, general content | Standard blog with clean typography |
| serif | Long-form writing, essays | Elegant reading-focused PostLayout example |
| curator | Link blogs, content aggregators | Related posts pattern, minimal hero |
| atelier | Photography, creative portfolios | Image-first layout, full-bleed hero |
| aperture | Photography studios | Gallery integration, portfolio grid |
| engineering | Technical blogs, documentation | Code-heavy PostLayout with syntax callouts |
| newsletter-hub | Newsletter archives, email-first blogs | Subscriber CTA patterns, newsletter hero |
| podcast | Podcast shows, audio content | Embedded player, episode listing |
| restaurant | Food, local business blogs | Location + hours schema, menu integration |