Skip to content

Project Structure

A scaffolded Plumix project is seven files, and two of them are yours to write. The rest configure the tooling. Everything the site does arrives through the plumix package and whichever plugins you installed.

Here is a project created with the blog and media plugins and GitHub sign-in, which is what adds the eighth file:

my-site/
├── plumix.config.ts the site: runtime, database, auth, plugins, theme
├── theme/
│ └── index.tsx the public site: templates, tokens, document manifest
├── wrangler.jsonc Cloudflare bindings, assets, cron triggers
├── package.json dependencies and the four scripts
├── tsconfig.json compiler options, and what is in the program
├── .dev.vars local secrets, written only when a slot needs one
├── .gitignore
└── README.md

Nothing here is a copy of a fixed template. The scaffolder resolves your runtime and plugin choices into imports, config slots and registrations, then splices them into one config file. Choosing the media plugin is what put storage and imageDelivery in the config and an r2_buckets entry in wrangler.jsonc.

Two directories appear later. drizzle/ holds the SQL migrations plumix migrate generate emits. .plumix/ holds everything the build generates, and both it and .wrangler/ are in .gitignore.

plumix.config.ts

The one file the CLI has to find. It default-exports the result of plumix({ ... }), which is where the runtime adapter, the database adapter, the auth setup, the plugin array and the theme meet. A site with two plugins that both register an entry type called recipe fails here, at boot, rather than at the first request.

The CLI locates it as plumix.config.ts, .js or .mjs in the project root; --config <path> overrides that.

theme/index.tsx

The public site. defineTheme takes the template list, the design tokens, the breakpoints, the theme’s own blocks and the document manifest. The scaffolded version registers three rules against two template components. fallback(index) answers anything with no better match, entry(single) answers any single entry, and forEntryType("page").template(single) is what makes the pages plugin’s page type render once you install it. The document manifest sets a titleTemplate and nothing else. It is deliberately plain so you replace it rather than unpick it.

A site that registers no theme at all still renders. The framework falls back to a built-in welcome theme, so a config without a theme slot serves a self-contained welcome page instead of an error.

wrangler.jsonc

Cloudflare’s own config, not Plumix’s. It declares the D1 binding, points main at the generated .plumix/worker.ts, serves .plumix/public through the assets binding, and sets two cron triggers.

Those triggers matter more than they look. 0 3 * * * fires the core session-cleanup task and */5 * * * * fires publish-scheduled, which is what moves a scheduled entry to published. Remove them and scheduled entries stay scheduled with no error to explain why.

The generated database_id is the placeholder local-development-only, which is enough for local D1 and not enough to deploy.

package.json

Four scripts. dev and build wrap the CLI as plumix dev and plumix build. typecheck is tsc --noEmit, and clean is git clean -xdf .plumix .wrangler dist node_modules, so neither of those two goes through Plumix at all. The dependencies are plumix, the runtime adapter, react, and whichever plugin packages you ticked.

tsconfig.json

Strict, moduleResolution: "bundler", verbatimModuleSyntax, noUncheckedIndexedAccess, and types set to Node, the Workers types and React. Its include is three entries: plumix.config.ts, theme, and .plumix. That last one is why a type error in generated code reaches your editor.

.dev.vars

Local secret values for plumix dev, one NAME= per line, and only written when something in your selection needs one. Picking GitHub OAuth is what puts GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET there. It is gitignored, and production secrets go to the deploy rather than into this file.

.plumix/ is regenerated from your config on every dev start and every build. Do not edit it, and do not commit it. Six files land there.

worker.ts is the Worker entry point, which is why wrangler.jsonc points main at it rather than at anything you wrote. schema.ts is the resolved database schema, core’s tables plus every table your plugins declare, and it is what plumix migrate generate hands to drizzle-kit.

Four more are client entry points, each bundled as its own chunk so the browser fetches only what a page needs.

client-entry.ts carries the CSS your theme declares, and in development it lazy-imports plumix/core/dev-client behind Vite’s import.meta.hot gate. That import is what installs the island error dialog, the compile-error overlay and the forwarder that prints browser errors in your terminal. Nothing in the module runs on import, so the whole thing tree-shakes out of a production build along with the gate.

islands-entry.ts is a single side-effect import of plumix/blocks/island-runtime, which registers the <plumix-island> custom element and the hydration strategies. The server injects its script tag only on a page that contains at least one island. islands-renderer-entry.ts re-exports plumix/blocks/island-renderer, the React half, which the custom element dynamic-imports on first hydration. React therefore never loads on a page whose islands all defer.

editor-entry.ts calls bootEditor from plumix/editor-runtime to mount the block editor canvas, and the server injects it only when the edit gate authorizes the request.

public/ is the staged asset directory the assets binding serves.

Import from plumix, never from @plumix/core

Section titled “Import from plumix, never from @plumix/core”

Every example on this site imports from plumix or one of its subpaths. That is the rule, stated once here and assumed everywhere else.

The plumix package is a façade over several published packages. @plumix/core, @plumix/blocks and the three @plumix/admin* packages are all on npm and all resolvable, and none of their package metadata says you should not import them. Reach for one anyway and you are writing against the half that moves without warning. plumix re-exports a curated surface, and a rename behind it is only a breaking change if it crosses the façade.

// Wrong. Resolves, compiles, and breaks on a minor version.
import { defineBlock } from "@plumix/blocks";
import { definePlugin } from "@plumix/core";
import { defineBlock } from "plumix/blocks";
import { definePlugin } from "plumix/plugin";

The rule is about the packages the façade covers, which are @plumix/core, @plumix/blocks and the three @plumix/admin* packages. Anything you install yourself you import by its own name.

@plumix/runtime-cloudflare is the one you meet immediately. It is a direct dependency of your project and a direct import, because a runtime is a thing you choose, and cloudflare, d1, r2, images, kv and edge all come from there. The plugin packages work the same way. @plumix/plugin-blog, @plumix/plugin-pages, @plumix/plugin-menu, @plumix/plugin-comments, @plumix/plugin-media, @plumix/plugin-audit-log and @plumix/plugin-og are each imported by name, and a plugin may publish subpaths of its own, such as @plumix/plugin-media/fields and @plumix/plugin-og/takumi.

The root. Config (plumix, auth), the request context, hooks, redirects, access policy, telemetry, and every type the rest of the surface refers to. It is also the specifier every declare module block targets.

The subpaths below are not disjoint halves of the root. Most of them narrow it to what one job needs, and a few widen it. Importing definePlugin from plumix and from plumix/plugin gets you the same binding.

import { auth } from "plumix";
export const recipeAuth = auth({
passkey: {
rpName: "Recipes",
rpId: "localhost",
origin: "http://localhost:5173",
},
});

The entire root surface, plus valibot re-exported as v so a plugin can declare its own config schema without a second dependency. definePlugin and the registration context are what you come here for, and both are on the root specifier as well.

import { definePlugin } from "plumix/plugin";
export const recipes = definePlugin("recipes", {
setup: (ctx) => {
ctx.registerEntryType("recipe", {
label: "Recipes",
labels: { singular: "Recipe", plural: "Recipes" },
hasArchive: true,
rewrite: { slug: "recipes" },
});
},
});

What a runtime adapter needs to contribute CLI commands: CliError, which the command dispatcher understands, and the spawnInherit / spawnCapturingStderr helpers a command shells out through. Narrow on purpose — the root specifier carries the whole runtime, and a command author who reaches for it pays ~500ms of module evaluation before the CLI has parsed a flag.

import type { CommandDefinition } from "plumix";
import { spawnInherit } from "plumix/cli";
export const deployCommand: CommandDefinition = {
describe: "Deploy to Cloudflare (via wrangler)",
async run(ctx) {
await spawnInherit("wrangler", ["deploy", ...ctx.argv], { cwd: ctx.cwd });
},
};

defineTheme, defineTemplate and the document-manifest types, narrowed to what a theme file needs. All of them are on the root specifier too. The template builders fallback, entry and forEntryType are only on the root, which is why the scaffolded theme imports the whole set from plumix rather than splitting the import in two.

import type { EntryData } from "plumix";
import { defineTemplate } from "plumix/theme";
export const recipe = defineTemplate<EntryData>({
render: ({ data }) => (
<article>
<h1>{data.entry.title}</h1>
</article>
),
});

The Vite plugin, plus emitPlumixSources for tooling that has to force the generated files into existence before Vite starts. You rarely import it by hand, because plumix dev and plumix build wire it up.

import { plumix } from "plumix/vite";
export default {
plugins: [plumix({ configFile: "plumix.config.ts" })],
};

The admin ships precompiled and is closed for modification. A plugin contributes to it by shipping an adminEntry module, which the build bundles into a chunk the admin loads at runtime. Inside that chunk you write bare imports as normal, and the build rewrites react, radix-ui, @tanstack/react-query and eleven more to the shims below, so your chunk shares the shell’s singletons rather than bundling its own. plumix/admin/react carries the worked example the other thirteen refer back to.

The hand-imported half: getRuntime, which reads the shared runtime object off window.plumix, the constants naming which specifier maps to which shim, and createPluginRpcClient — the one client every plugin’s admin code calls its own server-registered RPC procedures through, so no plugin hand-rolls the wire envelope, headers, or error unwrapping. It takes the type of the router the plugin handed ctx.registerRpcRouter, imported with import type so the server module stays out of the admin bundle, and infers every procedure’s name, input and output from it — a renamed procedure or a reshaped output on the server is a type error at the call site.

import type { PluginRpcOutputs } from "plumix/admin";
import { createPluginRpcClient, getRuntime } from "plumix/admin";
import type { RecipesRouter } from "../rpc.js";
const rpc = createPluginRpcClient<RecipesRouter>("recipes");
export type Recipe = PluginRpcOutputs<RecipesRouter>["get"];
export function fetchRecipe(id: number): Promise<Recipe> {
return rpc.get({ id });
}
export function activeQueryClient(): unknown {
return getRuntime().reactQuery;
}

The React shim. Every export is read off the admin shell’s own React instance, so a plugin chunk shares hooks, context and the reconciler with the pages around it rather than mounting a second React.

import { useState } from "plumix/admin/react";
export function Counter(): React.ReactNode {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

You do not normally write that import. Write from "react" and let the plugin-chunk build rewrite it.

The automatic JSX runtime off the same React instance. Same mechanism as the React shim above; the compiler emits this import rather than you writing it.

react-dom off the shell’s instance. Same mechanism as the React shim above.

react-dom/client off the shell’s instance. Same mechanism as the React shim above. A plugin chunk almost never needs it, since the shell owns the root.

TanStack Query off the shell’s instance. Same mechanism as the React shim above, and the reason a plugin’s useQuery hits the cache the admin already populated instead of refetching.

TanStack Router off the shell’s instance. Same mechanism as the React shim above, so a plugin page navigates inside the admin’s router rather than reloading the document.

The oRPC client off the shell’s instance. Same mechanism as the React shim above. It is how a plugin page calls the RPC routes its own server half registered.

The oRPC fetch link, @orpc/client/fetch. Same mechanism as the React shim above.

The bridge between oRPC and TanStack Query. Same mechanism as the React shim above.

Lingui’s i18n singleton, which carries the loaded catalogs and the active locale. Same mechanism as the React shim above. Sharing it is what stops a plugin chunk from seeing an empty catalog.

Lingui’s React bindings, including the provider the admin mounts at boot. Same mechanism as the React shim above; without the shim, useLingui() inside a plugin route returns null.

Radix primitives off the shell’s instance. Same mechanism as the React shim above. They carry React context, so a plugin’s <Tooltip> finds the shell’s <TooltipProvider> only because both sides are the same module.

The toast library. Same mechanism as the React shim above. toast() is a module singleton bound to the <Toaster> the shell mounted, so a second copy would render nothing.

The class-merging helper behind cn(). Same mechanism as the React shim above.

The shared component library the admin renders. It, plumix/admin above, and plumix/admin/test below are the three admin subpaths you import by hand; every other entry in this list is a shim the plugin-chunk build rewrites to. Unlike the shims it ships real source, which the plugin-chunk build bundles into your chunk with its own React and Radix imports aliased to the shims. A wrapper costs about a kilobyte; Radix itself is not duplicated.

import { Button } from "plumix/admin/ui";
export function PublishButton(): React.ReactNode {
return <Button variant="default">Publish</Button>;
}

The vitest counterpart to createPluginRpcClient above: stubPluginRpc serves a plugin’s own RPC procedures from a test by substituting fetch, and PluginRpcError answers a route with a specific oRPC error shape (status, code, data) instead of a generic 500. Aimed at a plugin author testing an admin shell component that calls its own procedures.

import { stubPluginRpc } from "plumix/admin/test";
stubPluginRpc("recipes", {
list: () => [{ id: 1, title: "Caponata" }],
});

The block value API: defineBlock, the registry, renderBlockTree, validateEntryContent, marks, shortcodes, variations and transforms.

import { defineBlock } from "plumix/blocks";
export const cookTime = defineBlock({
name: "recipes/cook-time",
title: "Cook time",
inputs: [{ name: "minutes", type: "number", label: "Minutes" }],
defaults: { minutes: 30 },
render: ({ attrs }) => `${String(attrs.minutes)} minutes`,
});

The render-time primitives a theme or a block reaches for: Link and Image, PlumixProvider, and hooks like useAuth, useTokens, useQueriedEntry and useIsEditing.

import { Image, Link } from "plumix/blocks/renderer";
export function RecipeCard(): React.ReactNode {
return (
<Link href="/recipes/sicilian-caponata">
<Image
src="/media/caponata.jpg"
width={640}
height={360}
alt="Caponata"
/>
</Link>
);
}

Helpers for testing a block you wrote: render one spec or a whole tree to HTML, and validate content against a registry.

import { defineBlock } from "plumix/blocks";
import { renderBlockSpecToHtml } from "plumix/blocks/test";
const cookTime = defineBlock({
name: "recipes/cook-time",
title: "Cook time",
inputs: [{ name: "minutes", type: "number", label: "Minutes" }],
render: ({ attrs }) => `${String(attrs.minutes)} minutes`,
});
export const html = renderBlockSpecToHtml(cookTime, { minutes: 45 });

The browser half of the island system. Importing it registers the <plumix-island> custom element and the hydration strategies, so it is imported for the side effect and nothing else. The generated .plumix/islands-entry.ts is one line:

import "plumix/blocks/island-runtime";

The React half of the island system, split into its own chunk. The custom element dynamic-imports it the first time an island actually hydrates, which keeps React off a page whose islands all defer. The generated .plumix/islands-renderer-entry.ts re-exports it:

export * from "plumix/blocks/island-renderer";

bootEditor, which mounts the block editor canvas into the server-rendered content root. The generated .plumix/editor-entry.ts calls it, and the server injects that chunk only when the edit gate authorizes the request.

import { bootEditor } from "plumix/editor-runtime";
bootEditor();

installDevClient, the single install point for the development-only browser tools: the island error dialog, the compile and import error overlay, and the forwarder that prints browser errors to your terminal. The generated client entry calls it behind Vite’s import.meta.hot gate, so production never sees it.

import { installDevClient } from "plumix/core/dev-client";
installDevClient();

The database schema as types and tables: the entries, terms and users tables among others, plus the unions behind them such as EntryStatus and UserRole.

import type { EntryStatus } from "plumix/schema";
export const publicStatuses: readonly EntryStatus[] = ["published"];

The direct-write toolkit. Drizzle’s query operators, the schema tables, table introspection and the cache-tag vocabulary, all in one place so a plugin writing through ctx.db never takes its own drizzle-orm dependency.

It also carries readVisitorMeta, which turns the address the runtime reported on ctx.clientAddress into a salted, per-install hash, alongside the user-agent on ctx.request — what a public submission handler needs to rate-limit or attribute without keeping the address itself. A visitor whose address the runtime could not resolve falls into one shared bucket; a forwarding header they set themselves is never read.

import { and, eq } from "plumix/db";
import { entries } from "plumix/schema";
export const publishedRecipes = and(
eq(entries.type, "recipe"),
eq(entries.status, "published"),
);

The libSQL database adapter, on its own subpath so its driver stays out of a bundle that never imports it. It is a single endpoint with strong consistency, which is the difference from D1’s read replicas.

import { libsql } from "plumix/db/libsql";
export const database = libsql((env) => ({
url: env.LIBSQL_URL,
authToken: env.LIBSQL_AUTH_TOKEN,
}));
declare module "plumix" {
interface PlumixEnv {
readonly LIBSQL_URL: string;
readonly LIBSQL_AUTH_TOKEN: string;
}
}

The Cloudflare CDN slot, on its own subpath so a site that configures no CDN never carries a vendor’s provider. It works from a Worker, a container or a VM alike, which is what makes the cdn: line the one line that survives a move between hosts — CDN Caching covers what it writes, what it stores and what each host gets. Alias the import, because every provider exports its bare vendor name and aliasing is what makes swapping one a single-word edit.

zoneId and purgeToken are required, and a deploy where either resolves to nothing leaves the CDN inert: pages render live, and the debug bar’s slot row is where that shows.

import { cloudflare as cdn } from "plumix/cdn/cloudflare";
export const cdnSlot = cdn({
ttl: 3600,
staleWhileRevalidate: 86400,
zoneId: (env) => env.CF_ZONE_ID,
purgeToken: (env) => env.CF_CACHE_PURGE_TOKEN,
});
declare module "plumix" {
interface PlumixEnv {
readonly CF_ZONE_ID: string;
readonly CF_CACHE_PURGE_TOKEN: string;
}
}

The S3 object-storage slot and the SigV4 signer behind it, on their own subpath for the reason plumix/db/libsql is: a deploy that binds a native bucket never carries the signer. s3() talks to any S3-compatible endpoint over fetch, so AWS S3, R2 through its S3 API, MinIO, DigitalOcean Spaces and GCS interop all go through one slot, and it always offers presignPut. The signer’s two forms, presignPutUrl and signRequest, are exported for a runtime adapter that needs them on their own.

import { s3 } from "plumix/storage/s3";
export const storage = s3({
bucket: "media",
region: "us-east-1",
endpoint: "https://s3.us-east-1.amazonaws.com",
credentials: (env) => ({
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
}),
publicUrlBase: "https://cdn.example.com",
});
declare module "plumix" {
interface PlumixEnv {
readonly S3_ACCESS_KEY_ID: string;
readonly S3_SECRET_ACCESS_KEY: string;
}
}

The meta-box field builders. Every builder is fluent and typed so that a chain which does not apply is a compile error. The reference factories are builders too, not options objects. user(key), entry(key, entryTypes) and term(key, taxonomies) each return a reference builder whose chain is gated by kind, so .roles() and .includeDisabled() compile only on a user reference and .status() and .includeTrashed() only on an entry one, while .multiple(), .max() and .returns() apply to all three.

import { number, repeater, select, text } from "plumix/fields";
export const recipeDetails = [
number("prepMinutes").label("Prep (minutes)").min(0),
number("servings").min(1).required(),
select("difficulty").options(["easy", "medium", "hard"]),
repeater("ingredients").fields([
number("amount"),
text("unit"),
text("item").required(),
]),
];

The translation surface: the Label type and resolveLabel, the formatDate, formatNumber and formatRelative helpers, and Lingui’s Trans, useLingui and i18n.

import { formatRelative } from "plumix/i18n";
export function publishedAgo(at: Date): string {
return formatRelative("en", at);
}

The unit-test surface: an in-memory database, a request context factory, entity factories, hook spies and a request builder.

import type { AppContext } from "plumix";
import { createTestContext, createTestDb } from "plumix/test";
export async function contextForRecipes(): Promise<AppContext> {
return createTestContext({ db: await createTestDb() });
}

The end-to-end surface, aimed at a plugin author testing admin pages against a running site: definePlumixE2EConfig, a test/expect pair with Plumix fixtures, actingAs for signing in as a role, and helpers for mocking RPC and the plugin manifest.

import { definePlumixE2EConfig } from "plumix/test/playwright";
export default definePlumixE2EConfig({ testDir: "./e2e" });

The slot conformance suites, aimed at an author implementing a runtime slot: one parameterised describe per port — describeKvContract, describeObjectStorageContract, describeCdnContract and describeAssetsContract — each taking a factory that binds a fresh instance. A factory declares what its backend cannot do rather than the suite guessing: a store that rejects sub-minute TTLs names its floor and is never asked to beat it. These run under vitest, so the consumer installs it.

import { memoryKv } from "plumix";
import { describeKvContract } from "plumix/test/conformance";
describeKvContract({ connect: () => memoryKv().connect() });

Configuration is the reference for the config file described above. Themes covers what goes in theme/index.tsx and how a request resolves to a template. Plugins covers installing published plugins and what a plugin may contribute. Bindings and Environment covers the wrangler.jsonc side of the runtime slots. Blocks covers the block tree the editor entry mounts against.

Four subpaths above have no page of their own, on purpose. plumix/blocks/island-runtime, plumix/blocks/island-renderer, plumix/editor-runtime and plumix/core/dev-client are the generated runtime entries. The scaffolder and the Vite plugin wire them, and a site never imports them by hand.

Read Configuration next for the twenty slots plumix.config.ts accepts and how a secret reaches one. Then Content Modelling, which turns the empty plugins array into a real content model. When you are ready to ship, Deploy Your Site covers the path to a live URL.