Theme Development

12 min readUpdated 27 Apr 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 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:

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": "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"
  }
}
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: JSON.stringify(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.

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:

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

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

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
HomeLayoutPropshomeAstPageASTYesOptional custom AST from pages.slug="home" — rendered in a "Custom content" section

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 submission 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
npm run typecheck passesnpm run typecheck

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