Skip to content

Theming

VexCMS supports database-driven theming: the site’s palette lives in a themes collection editors can modify from the admin panel, one theme is selected in a global, and the chosen tokens are injected as CSS custom properties — server-rendered for first paint, updated live on save. This is exactly how the VexCMS site itself is themed.

  • THEME_COLOR_TOKENS — the 32 shadcn colour-token keys, camelCased, each mapped to its custom property (cardForeground → --card-foreground).
  • THEME_SHARED_TOKENS — scheme-independent tokens read from the theme document root: radius → --radius, fontFamily → --font-sans.
  • EMBER_LIGHT / EMBER_DARK — a complete starter palette (oklch), so a freshly created theme forks a working look instead of presenting 32 empty pickers.
  • buildThemeCss({ theme, scope }) — turns a theme document into stylesheet text: :root { --background: …; } plus a .dark { … } block.
  • ThemeColorTokenKey, ColorField, ThemeScope — the types used below.

One color() field per token, per colour scheme, pinned to format: "oklch" (the notation the stylesheet declares its tokens in, so stored values interpolate with no conversion) and defaulted from the starter palette:

src/vexcms/collections/themeColors.ts
import {
color,
EMBER_DARK,
EMBER_LIGHT,
THEME_COLOR_TOKENS,
type ColorField,
type ThemeColorTokenKey,
} from "@vexcms/core";
const DEFAULTS = { light: EMBER_LIGHT, dark: EMBER_DARK };
/** Derives a label from a token key: "cardForeground" → "Card Foreground". */
function tokenLabel(key: string): string {
const spaced = key.replace(/([A-Z])/g, " $1").replace(/(\d+)/g, " $1");
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
/** The shadcn design-token set as color() fields for one colour scheme. */
export function themeColors(mode: "dark" | "light"): Record<ThemeColorTokenKey, ColorField> {
const fields = {} as Record<ThemeColorTokenKey, ColorField>;
for (const key of Object.keys(THEME_COLOR_TOKENS) as ThemeColorTokenKey[]) {
fields[key] = color({
format: "oklch",
label: tokenLabel(key),
defaultValue: DEFAULTS[mode][key],
});
}
return fields;
}

Note the token-defining fields do not set themeColors: true — a token whose value is var(--primary) written back to --primary is a custom-property cycle, which CSS discards. That option is for other fields (a block’s background override, say) that should follow the palette.

Light and dark schemes are group() fields of the factory’s output; shared tokens sit at the document root:

src/vexcms/collections/themes.ts
import { defineCollection, group, text } from "@vexcms/core";
import { themeColors } from "./themeColors";
export const themes = defineCollection({
slug: "themes",
interfaceName: "Theme",
admin: { useAsTitle: "name", icon: "Palette" },
fields: {
name: text({ label: "Theme Name", required: true, index: "by_name" }),
fontFamily: text({
label: "Font Family",
defaultValue: "Geist, Inter, system-ui, sans-serif",
description: "Applied to --font-sans. The first available font wins.",
}),
radius: text({
label: "Border Radius",
defaultValue: "4px",
description: "Applied to --radius. Any CSS length.",
}),
light: group({
label: "Light Mode",
description: "Tokens emitted under :root.",
fields: themeColors("light"),
}),
dark: group({
label: "Dark Mode",
description: "Tokens emitted under .dark.",
fields: themeColors("dark"),
}),
},
});

A relationship on the siteSettings global picks the theme; a Convex query resolves it through the Local API. RBAC is bypassed deliberately — a site’s palette is public by definition, and an anonymous visitor must get the same colours as a signed-in editor:

convex/theme.ts
import { getGlobal } from "@vexcms/core/server";
import config from "../src/vex.config.server";
import type { Doc, Id } from "./_generated/dataModel";
import { query } from "./_generated/server";
export const getActive = query({
handler: async (ctx): Promise<Doc<"themes"> | null> => {
const settings = await getGlobal({
ctx,
config,
slug: "siteSettings",
access: { bypass: true },
});
if (!settings) return null;
// relationship always stores an array of ids — hasMany only controls
// how many the admin picker lets you choose.
const reference = settings.activeTheme as string[] | undefined;
const themeId = reference?.[0];
if (!themeId) return null;
return await ctx.db.get(themeId as Id<"themes">);
},
});

A server component fetches the active theme and inlines buildThemeCss’s output, so the first paint is already themed — no flash of unthemed content. Read it through the same shared vex client (createVexServerClient, from Caching, sitemaps & revalidation) that your public pages use, never fetchQuery from convex/nextjs — ThemeStyle typically renders from a layout shared by every public route, so a fetchQuery call here forces the entire site dynamic, not just this component. See It’s the whole render tree, not just the page for why:

src/components/ThemeStyle.tsx
import { api } from "@convex/_generated/api";
import { buildThemeCss, type ThemeScope } from "@vexcms/core";
import { vex } from "~/lib/vex";
export async function ThemeStyle(props: { scope?: ThemeScope }) {
const scope = props.scope ?? "site";
let theme: Record<string, unknown> | null = null;
try {
theme = await vex.query(api.theme.getActive);
} catch {
// No deployment reachable at build time — fall back to the static stylesheet.
return null;
}
if (!theme) return null;
const css = buildThemeCss({ theme, scope });
if (!css) return null;
// Only the site block opts into React 19 style hoisting. A hoisted `<style>`
// (`href` + `precedence`) is a stylesheet *resource*: React keeps it in
// <head> for the life of the document and only decrements a refcount when
// the component that rendered it unmounts. That is right for a document-wide
// site theme and wrong for the admin theme — a hoisted `:root:root` block
// survives a client-side navigation out of `/admin` and re-skins the public
// site until a full reload. Rendering the admin block in place ties it to the
// admin layout's lifetime; it still beats the site block on specificity.
if (scope === "admin") {
return <style dangerouslySetInnerHTML={{ __html: css }} />;
}
return (
<style
dangerouslySetInnerHTML={{ __html: css }}
href="vex-theme-site"
precedence="high"
/>
);
}

Render <ThemeStyle /> from a layout every public route shares — not necessarily the true root layout; keep that free of Convex reads too if it also wraps /admin. When no theme is active (or Convex is unreachable at build time) it renders nothing and the app falls back to its static stylesheet unchanged.

buildThemeCss supports two scopes forming a specificity ladder no injection order can upset: "site" emits :root (0,1,0) and .dark; "admin" emits :root:root (0,2,0) and .dark:root:root. Emit the site theme once for the whole document and the admin theme from the admin layout only, and the admin block wins exactly where it renders — letting the admin panel carry its own palette (or, by default, adopt the site’s).

“Where it renders” is the catch: never hoist the admin block. A <style> with href + precedence is a React stylesheet resource, and React keeps resources in <head> for the life of the document — unmounting the admin layout does not remove it. Hoist the admin block and :root:root follows the visitor back to the public site on the next client-side navigation, until they hard-reload. Render it in place instead, as above.

<ThemeStyle /> covers first paint only. For edits to go live without a reload, add a small client component that appends its own <style> to the end of <body> and rewrites its text with buildThemeCss on change — saving a colour in the admin panel then restyles the site in real time. The end of <body> is the one position that follows both server blocks (the hoisted site block in <head> and the admin layout’s in-tree block), so at equal specificity the live block always wins.

Two rules decide whether it also works under live preview, and both are easy to get wrong:

Do the siteSettings -> theme join on the CLIENT, not in getActive. That query resolves the reference server-side, so it answers from the SAVED global and is structurally blind to an editor’s unsaved activeTheme — switching themes in preview changes nothing until save. Read the global through useLivePreviewDocumentQuery (so the overlay reaches it), then resolve whichever theme it now names through a second query that takes an id:

// convex/theme.ts — alongside getActive
export const byId = query({
args: { id: v.string() },
handler: async (ctx, args): Promise<Doc<"themes"> | null> =>
await ctx.db.get(args.id as Id<"themes">),
});
const { data: settings } = useLivePreviewDocumentQuery(
convexQuery(api.siteSettings.get, {}),
"siteSettings",
);
const themeId = (settings?.activeTheme as string[] | undefined)?.[0];
const { data: theme } = useQuery({
...convexQuery(api.theme.byId, themeId ? { id: themeId } : "skip"),
enabled: Boolean(themeId),
});

Outside preview the overlay is absent and this behaves exactly as the server join did, still as a live Convex subscription.

Own the <style> element in its own mount-scoped effect. Creating it inside the effect that writes the CSS means every dependency change runs that effect’s cleanup FIRST and removes the element, exposing the server CSS underneath for as long as the next query takes — a flash back to the saved theme, visible only the first time a given theme is selected because a cached one resolves in the same tick. Create and remove it in an effect keyed on the scope alone; write its textContent from a second effect, and hold the previous CSS (return early) while the new theme is still loading.

  • format: "oklch" for token fields. Custom properties accept any colour notation, but storing the notation your stylesheet already uses means values are written through verbatim — no conversion, no drift.
  • themeColors: true is for consumers, not definers. Token-defining fields storing var(--token) create a custom-property cycle CSS silently discards.
  • .dark must sit on <html>. buildThemeCss emits .dark as a compound selector on the assumption it’s on the root element (which is :root), never a descendant.
  • Font stacks degrade, not break. --font-sans is commonly also set by the host’s font loader at equal specificity — the later declaration wins, and a stack naming an unloaded font falls through to the next entry.