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 the marketplace reads to understand your 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": "jane@example.com",
"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: JSON.stringify(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.
Local preview with DemoFixtures
Testing your theme against real-looking data before submitting it to the marketplace is straightforward. The Theme SDK exports a DemoFixtures object with synthetic posts, site settings, and member session data. Pass it to your layout components during development:
import { DemoFixtures } from "@velocms/theme-sdk/testing";
import BlogLayout from "../src/BlogLayout";
// Use the DemoFixtures for local development
export default function Preview() {
return (
<BlogLayout
posts={DemoFixtures.posts}
settings={DemoFixtures.settings}
siteUrl="http://localhost:3000"
searchEnabled={false}
member={null}
currentPage={1}
totalPages={1}
/>
);
}The /preview/themes/[slug] route on a running VeloCMS instance does the same thing — it loads your registered theme and renders it against DemoFixtures. That's how the marketplace thumbnail is generated.
Marketplace submission
When you're ready to publish, head to /developers on VeloCMS. The submission flow asks for your theme.json manifest, a 600x400 thumbnail, at least two full-page screenshots, and a short description. Approval takes 2-5 business days — we review for accessibility, dark mode support, and mobile responsiveness before listing.
The revenue split is 80/20: you keep 80% of every sale, VeloCMS takes 20%. Free themes are listed without any revenue sharing. Payments are processed monthly via Stripe Connect, so you'll need a Stripe account when you submit.
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 | |
| HomeLayoutProps | homeAst | PageAST | Yes | Optional custom AST from pages.slug="home" — rendered in a "Custom content" section |
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 submission 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 |
| npm run typecheck passes | npm run typecheck |
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 |