Theme Development

12 min readUpdated 04 Oct 2026

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:

theme.json
{
  "$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"
  }
}
json

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:

src/BlogLayout.tsx
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>
  );
}
typescript
src/PostLayout.tsx
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>
  );
}
typescript

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);
  /* ... */
}
css

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:

preview/index.tsx (illustration)
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}
    />
  );
}
typescript

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

FieldTypeRequiredDescription
$schemastringNoJSON Schema URL for editor validation
namestringYesnpm-style unique ID: velocms-theme-{name}
displayNamestringYesHuman-readable name shown in the marketplace
versionstringYesSemantic version (e.g. 1.0.0)
descriptionstringYesShort description shown in marketplace card
author.namestringYesTheme author name
author.emailstringYesAuthor contact email
author.urlstringNoAuthor website URL
type"layout"YesAlways 'layout' for theme packages
category"personal" | "magazine" | "business" | "portfolio" | "niche"YesMarketplace category for filtering
tagsstring[]NoSearchable tags (e.g. minimal, dark, reading)
engines.velocmsstringYesSemver range of compatible VeloCMS versions
preview.thumbnailstring (path)Yes600×400 PNG relative path. Required for marketplace listing.
preview.screenshotsstring[]Yes2+ full-page PNG paths at 1440×900
exports.components.BlogLayoutstring (path)YesRelative path to BlogLayout component file
exports.components.PostLayoutstring (path)YesRelative path to PostLayout component file
exports.components.PageLayoutstring (path)YesRelative path to PageLayout component file
pricing.model"free" | "one_time" | "subscription"YesRevenue model. free = no revenue split.
pricing.pricenumberNoPrice 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.

InterfacePropTypeOptionalDescription
BlogLayoutPropspostsPost[]No
BlogLayoutPropssettingsSiteSettings | nullNo
BlogLayoutPropssiteUrlstringNo
BlogLayoutPropssearchEnabledbooleanNo
BlogLayoutPropsmemberMemberSession | nullYes
BlogLayoutPropscurrentPagenumberYes1-indexed current page — defaults to 1 when no ?page= query param
BlogLayoutPropstotalPagesnumberYesTotal number of pages in the listing — 1 means no pagination
PostLayoutPropspostPostNo
PostLayoutPropssettingsSiteSettings | nullNo
PostLayoutPropssiteUrlstringNo
PostLayoutPropsjsonLdunknownNo
PostLayoutPropsmemberMemberSession | nullYes
PostLayoutPropsrelatedPostsRelatedPostCard[]YesOptional related posts — rendered by themes that support it (Curator, Serif)
PageLayoutPropssettingsSiteSettings | nullNo
PageLayoutPropsmember{ email: string; tier: string } | nullNoMinimal member shape — matches getMemberSession() return
PageLayoutPropssiteUrlstringNoSite URL for absolute link building (JSON-LD, share CTAs)
PageLayoutPropschildrenReactNodeNoRendered AST content goes here
HomeLayoutPropspostsPost[]NoFirst 6 published posts — fetched server-side in home/page.tsx
HomeLayoutPropssettingsSiteSettings | nullNo
HomeLayoutPropsmember{ email: string; tier: string } | nullNo
HomeLayoutPropssiteUrlstringNo

OKLCH token reference

CSS variableDefault (light)Default (dark)Usage
--backgroundoklch(0.98 0 0)oklch(0.12 0.005 240)Page background
--foregroundoklch(0.15 0 0)oklch(0.95 0 0)Primary text
--cardoklch(1 0 0)oklch(0.17 0.005 240)Card / panel background
--card-foregroundoklch(0.15 0 0)oklch(0.95 0 0)Text on cards
--mutedoklch(0.96 0.005 240)oklch(0.22 0.005 240)Muted surface (code bg, table header)
--muted-foregroundoklch(0.55 0.01 240)oklch(0.65 0.01 240)Secondary text, placeholders
--borderoklch(0.9 0.005 240)oklch(0.27 0.005 240)Borders, dividers
--ringoklch(0.55 0.16 264)oklch(0.65 0.16 264)Focus ring
--primaryoklch(0.55 0.16 264)oklch(0.65 0.16 264)Brand accent, links, buttons
--primary-foregroundoklch(0.98 0 0)oklch(0.12 0 0)Text on primary-colored surfaces
--destructiveoklch(0.55 0.2 25)oklch(0.65 0.2 25)Error states, delete actions
--accentoklch(0.96 0.005 240)oklch(0.22 0.005 240)Hover highlights
--accent-foregroundoklch(0.15 0 0)oklch(0.95 0 0)Text on accent surfaces

Tailwind classes mapped to tokens

Tailwind classCSS variableUse for
bg-background--backgroundPage body, outermost wrapper
text-foreground--foregroundAll body copy
bg-card--cardPost cards, sidebar panels
text-card-foreground--card-foregroundText inside cards
bg-muted--mutedCode blocks, table headers, callout backgrounds
text-muted-foreground--muted-foregroundBylines, timestamps, captions
border-border--borderAll borders and dividers
ring-ring--ringFocus outlines (accessibility)
bg-primary--primaryCTA buttons, active nav items
text-primary--primaryLinks, accent text
text-primary-foreground--primary-foregroundText on primary-bg buttons
bg-destructive--destructiveError banners, delete buttons

Theme quality checklist

RequirementHow to verify
BlogLayout renders at 375px (mobile)Chrome DevTools → iPhone SE viewport
PostLayout renders at 375pxSame as above
Dark mode works correctlyToggle .dark class on <html> in DevTools
No hardcoded hex/rgb/hsl colorsgrep -r '#[0-9a-f]\\{3,6\\}\\|rgb\\|hsl' src/
<h1> is present exactly once in PostLayoutChrome → 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 PostLayoutView page source → search for application/ld+json
thumbnail.png is exactly 600×400file thumbnail.png → dimensions check
At least 2 screenshots at 1440×900file *.png → dimensions check

Built-in theme implementations

Theme slugBest forStart here for…
terminalDeveloper blogs, changelogsSimplest possible theme — 200 lines per layout
defaultPersonal blogs, general contentStandard blog with clean typography
serifLong-form writing, essaysElegant reading-focused PostLayout example
curatorLink blogs, content aggregatorsRelated posts pattern, minimal hero
atelierPhotography, creative portfoliosImage-first layout, full-bleed hero
aperturePhotography studiosGallery integration, portfolio grid
engineeringTechnical blogs, documentationCode-heavy PostLayout with syntax callouts
newsletter-hubNewsletter archives, email-first blogsSubscriber CTA patterns, newsletter hero
podcastPodcast shows, audio contentEmbedded player, episode listing
restaurantFood, local business blogsLocation + hours schema, menu integration