Skip to content

Overview

A theme is the presentation layer of a Plumix site. It holds the templates that turn a resolved request into HTML, along with the design values, head tags and stylesheets those templates read. You declare it once with defineTheme and pass it to plumix.config.ts.

A theme is static. defineTheme takes a descriptor, validates it and hands the same object back. There is no setup step and no registration context, so a theme cannot run code at boot the way a plugin does. Everything it contributes is spelled out in the object literal.

A site has one theme, and it arrives through the theme slot in plumix.config.ts. No plugin can register one.

templates is the only required slot. It holds an array of rules built by the template builders, or a bare component as shorthand for a theme with nothing but a catch-all. The rest are optional:

  • document is the <head> descriptor: title, titleTemplate, meta, link, script, plus attributes for <html> and <body>, canonical: false for a page that should carry no canonical link, and viewport for the viewport, theme-color and color-scheme tags. A template can merge its own fragment over it per render.

  • tokens are named design values grouped by CSS property, and they are a registry rather than a stylesheet. Plumix emits no CSS for them. Picking a token in the editor writes var(--plumix-<group>-<slug>, <value>) into the block’s style, where <group> is the group name in kebab-case and <value> is the token’s registered literal, used as the inline fallback. A token registered without a value gets the bare reference and no fallback. Your own stylesheet declares the custom property. The known groups are color, spacing, fontFamily, fontSize, fontWeight, lineHeight, letterSpacing, borderWidth, borderRadius, boxShadow, textShadow, backgroundImage and maxWidth, and a theme may add a group for any other CSS property.

  • breakpoints sets the tablet and mobile max-widths in pixels, defaulting to 991 and 640. The server style emitter and the editor canvas read the same two numbers, so what an author previews is what ships.

  • blocks and shortcodes are presentation components the theme owns. They merge into the per-app registries at boot at the highest precedence, so a theme shortcode replaces a plugin or core shortcode of the same name. A theme block only ever settles against a plugin block, because the core/ namespace is reserved and defineTheme throws on a theme block that claims it.

  • allowedBlocks lists the blocks the editor allows, site-wide, for a site that offers only its own components and the core blocks it picked. Leave it out and every registered block is allowed. It is an allowlist: a plugin’s block is allowed only once the list names it, and a name no one registers is ignored. The theme’s own blocks are always allowed without being named; set inserter: false on one to keep it out of the inserter. A block that only lives inside a parent’s slot follows its parent, so core/columns brings core/column and core/table brings its rows and cells. The server holds every writer to the same list, agents included: a save can’t add a new instance of a block outside it, while an entry that already holds one keeps it, renders it and saves it. The editor shows such a block with a notice, editable but not duplicated, copied or pasted. Patterns and variations holding a block outside the list are hidden. A block’s own entryTypes and capability narrow the inserter further.

    defineTheme({
    templates: [fallback(Page)],
    allowedBlocks: [
    "core/rich-text",
    "core/button",
    "core/columns",
    "forms/form",
    ],
    });
  • allowCustomStyles sets the style policy: whether the editor and anything writing content may store custom style values, or only your tokens. Leave it out, or set true, and every style control takes a custom value. Set false and every control offers only tokens: the Custom input is gone, a property whose category has no tokens is hidden, and the Custom CSS and class-name fields are hidden. Keyword options such as border style, alignment and display stay. An array names the categories that keep custom values, and every other category is tokens only. A category is a token group (color, spacing, fontSize, …) or, for a property with no shared group, the property itself (width, height, opacity, …). A value counts as a token only when it names a token the theme declares. The server holds every writer to the same rule, an agent writing over MCP or the RPC included: a save can’t store a new custom value, a changed one, a token you don’t declare, a Custom CSS declaration or a class name. An entry that already stores one keeps it and renders it, and the editor shows it read-only with a way to clear it or pick a token. A value equal to a block’s own defaultStyles is always allowed, a duplicated or pasted block drops the values the policy refuses, and the editor’s inserter hides patterns and variations holding one.

    defineTheme({ allowCustomStyles: false }); // tokens only
    defineTheme({ allowCustomStyles: ["width", "height"] }); // tokens only, except these
  • redirects are public-route rules the theme owns, for URL moves that belong to the design rather than to the content model. They lose a tie against config.redirects and against plugin-registered rules.

  • css lists stylesheet paths. The strings never enter the config module graph. The Vite plugin generates a client entry that imports each one, so Vite emits hashed bundles.

  • viewTransitions turns on the browser’s animated transitions between pages. Core emits the CSS rule that enables them, and your stylesheet writes every animation. Page Transitions covers the setting and has recipes to copy.

  • Template dep kinds sit at the top level too, one array of slugs per kind. A theme declaring settings: ["site"] has that data loaded and passed into every template’s render.

Slugs are yours to choose. One convention is worth knowing: @plumix/plugin-og paints its bundled social card from color.background, color.foreground and color.muted-foreground, so a theme spelling those three that way gets a social card in its own palette the moment the plugin is installed. It is all three or none — name two and the card keeps its own — and the default card’s palette covers the option that points it at whatever your theme calls them instead.

  1. Write the theme. Create theme/index.tsx:

    import type { EntryData } from "plumix/theme";
    import { BlockRenderer } from "plumix/blocks/renderer";
    import { defineTemplate, defineTheme, entry, fallback } from "plumix/theme";
    const listing = defineTemplate({
    render: () => (
    <main>
    <h1>Recipes</h1>
    </main>
    ),
    });
    const recipe = defineTemplate<EntryData>({
    render: ({ data }) => (
    <article>
    <h1>{data.entry.title}</h1>
    <BlockRenderer content={data.entry.contentBlocks} />
    </article>
    ),
    });
    export const recipesTheme = defineTheme({
    templates: [fallback(listing), entry(recipe)],
    document: {
    titleTemplate: (title) => (title ? `${title} | Recipes` : "Recipes"),
    },
    });
  2. Register it. In plumix.config.ts, import recipesTheme from ./theme and pass it as the theme slot.

  3. Start the dev server.

    Terminal window
    pnpm dev

    Sicilian Caponata now renders through the entry template at /recipes/sicilian-caponata. Every other public URL, the archive at /recipes included, falls through to fallback.

    The /recipes/* routes exist because a plugin registered the recipe entry type. A theme targets types, it does not declare them, and Entry Types is where recipe comes from.

defineTemplate wraps a render function and brands the result, so the framework can tell a real template from an object that happens to have a render key. The render function takes one argument, and most templates destructure it.

data is the discriminated union of everything a request can resolve to, keyed by kind. Which member arrives depends on which rule matched, and the builder you bind with types it. ctx is the request context, carrying db, request, user, locale, config, events, filters and the rest. What you wrote in plumix.config.ts is read from ctx.config, so the base path is ctx.config.basePath. Declared template deps arrive as further keys on the same argument, one per kind, each a record keyed by what the template declared — a slug for most kinds, a location id for menus.

A template may also carry document, either a literal manifest or a function called per request with the same argument render sees. Both forms resolve after the walk has picked a winner, and merge over the theme’s manifest for that request alone. That is how one page contributes an og:image without every page paying for it. The function may be async, and it runs after the template deps and the block loaders on the page’s own content, so it can await the same ctx.memo lookup a loader made, as Blocks shows. It may also throw pageNotFound() or redirectTo() from plumix/support to answer the request with the theme’s 404 or a redirect instead.

Every public page gets <link rel="canonical" href="…">, written from the site origin and the request path after every render.document handler has run. A page that is the canonical address of nothing shareable — an account view, a per-user shortlist — sets canonical: false in its document and gets none:

import type { EntryData } from "plumix/theme";
import { defineTemplate } from "plumix/theme";
export const shortlist = defineTemplate<EntryData>({
document: ({ data }) =>
data.entry.slug === "shortlist" ? { canonical: false } : {},
render: ({ data }) => <main>{data.entry.title}</main>,
});

A function decides it per entry, so other entries the same template renders keep their canonical. Set on the theme’s document, it covers every page, and a template that leaves it unset inherits it. The only value is false: a template cannot turn the tag back on with true. It stops the automatic tag only. A <link rel="canonical"> the page declares in link still renders, and so does an SEO plugin canonical override. A noindex page still gets the automatic canonical unless it opts out too.

Every page carries one <meta name="viewport" content="width=device-width, initial-scale=1">. The viewport field of document changes it, along with the theme-color and color-scheme tags the browser also reads before first paint. It merges field by field, so a template can add one field and keep the rest of the theme’s. The renderer writes those three tags itself, once each, so a meta entry named viewport, theme-color or color-scheme throws a DocumentManifestError naming the field to use.

Set viewportFit: "cover" to lay the page under a phone’s notch and home indicator. env(safe-area-inset-*) then has values, and a fixed panel pads itself clear of them:

defineTheme({
templates: [fallback(Page)],
document: {
viewport: {
viewportFit: "cover",
interactiveWidget: "resizes-content",
themeColor: [
{ color: "#ffffff", media: "(prefers-color-scheme: light)" },
{ color: "#0b0b0c", media: "(prefers-color-scheme: dark)" },
],
colorScheme: "light dark",
},
},
});
.bottom-sheet {
padding-bottom: env(safe-area-inset-bottom);
}

The fields are width, height, initialScale, minimumScale, maximumScale, userScalable, viewportFit and interactiveWidget for the viewport tag, which keeps width=device-width and initial-scale=1 unless you set them. themeColor is a color, or a list with one tag per media query, and a later list replaces an earlier one whole. colorScheme renders only when set.

The theme slot is optional. A config that omits it gets welcomeTheme, a built-in substituted at config resolution, so nothing downstream can tell a theme-less site from one with a theme of its own.

It is the smallest theme there is: one fallback rule, one defineTemplate, no tokens and no stylesheet. The rendered screen inlines its CSS rather than linking one, so it fetches nothing, and it reads ctx.locale.code to pick its strings. Catalogs ship for English, German, Ukrainian, Arabic and Simplified Chinese, but only the English one carries strings today. The other four are empty .po files, and lingui compile backfills an empty entry with its English source, so every locale renders English until a translator fills one in. It carries a link to /_plumix/admin, built through ctx.config.basePath so it stays right on a site served from a subdirectory.

Its document manifest sets robots to noindex. A welcome screen reaching production is a misconfiguration, and search engines should not index one.

An entry’s body is a block tree rather than a string of HTML. Render it with <BlockRenderer content={...} /> from plumix/blocks/renderer. The tree comes off data.entry.contentBlocks, which is the stored content narrowed to the block-tree shape, and null when there is no content yet or the stored JSON does not match that shape. Pass it to BlockRenderer as it is: a null renders nothing on the page, and in the editor it still gives the canvas the root it mounts into, so a template that skips BlockRenderer for an empty entry leaves a new entry with nowhere to add blocks. The raw data.entry.content stays loose so a non-block serializer keeps working. Every node in that tree resolves to one of the specs in Core Blocks, unless a plugin or the theme registered its own.

The same subpath carries <Link> and <Image>. <Link> takes a resolved entry or term and uses the url already on it, and applies the site’s base path to a plain href. <Image> runs the site’s image handling. Reach for both instead of a bare <a> or <img>.

Every rule in the templates array comes from a builder, and Templates enumerates all sixteen of them with an example each. Template Hierarchy is how one of those rules gets picked for a given request, and Template Data is the union your render function destructures.

Custom Rule Kinds is for the other direction: a plugin adding a slot of its own beside templates, resolved by the same walk. The entry types and taxonomies your templates target are declared in a plugin, not in the theme, and Content Modelling covers how. Blocks covers the blocks a theme’s pages are built from. The theme slot sits beside every other slot in Configuration. A theme’s mail field restyles the mails core and plugins send, which Mail covers.

Read Templates next, then Template Hierarchy, which together cover what you declare and how a request finds it.

Four further pages in this section are not written yet, and what they cover already ships. Document Manifest goes through DocumentManifest and registerDocumentManifest, with which a plugin contributes head tags that core merges after the theme’s own at boot. Tokens and Breakpoints covers the token groups above and the var(--plumix-*) references a theme declares them for. Component Primitives documents Link, Image, PlumixProvider, the hooks that read its value such as useUser, useTokens and useBasePath, and useAuth, which fetches the signed-in visitor on the client. Template Dependencies covers declaring a dep kind on a theme or a template, and the override and extend forms.

When the theme renders, Routing explains which URLs reach it and Deployment takes the result to Cloudflare Workers.