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.
Building blocks from @vexcms/core
Section titled “Building blocks from @vexcms/core”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.
1. A token-field factory
Section titled “1. A token-field factory”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:
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.
2. The themes collection
Section titled “2. The themes collection”Light and dark schemes are group() fields of the factory’s output; shared
tokens sit at the document root:
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"), }), },});3. Selecting the active theme
Section titled “3. Selecting the active theme”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:
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">); },});4. Injecting the CSS
Section titled “4. Injecting the CSS”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:
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.
Site vs admin scope
Section titled “Site vs admin scope”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.
Live updates
Section titled “Live updates”<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 getActiveexport 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.
Gotchas
Section titled “Gotchas”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: trueis for consumers, not definers. Token-defining fields storingvar(--token)create a custom-property cycle CSS silently discards..darkmust sit on<html>.buildThemeCssemits.darkas a compound selector on the assumption it’s on the root element (which is:root), never a descendant.- Font stacks degrade, not break.
--font-sansis 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.