@vinumcms/admin
v0.20.0
Published
Admin routes, the vinumRoutes() factory, and the admin UI kit.
Readme
@vinumcms/admin
The back office: fifteen admin screens, the public CMS routes, the page editor and the pickers.
This package carries the setup guide for all of Vinum, because it is the one
you cannot skip — mounting vinumRoutes() is what turns five installed packages
into a working CMS.
Getting started
Requirements: React Router v7 or v8 with SSR, Tailwind v4, and a runtime
with Web Crypto — Deno in production, Node under react-router dev. On v8 come
its own floors: React ≥19.2.7 and Vite ≥7. npx create-react-router@latest
gives you a suitable app.
Eleven steps. Four of them are the ones people lose an evening to, and they are
marked. npx vinumcms doctor checks every one of them and tells you which is
missing, so the fastest way through this page is to follow it and then run that.
1. Install
npm install @vinumcms/core @vinumcms/adapters @vinumcms/server @vinumcms/react @vinumcms/admin
npm install @vinumcms/mail # optional: subscribers and mailingsName every package you import. They depend on each other, but your app imports
them directly, so each one has to be in your package.json.
2. Wire it up
app/vinum.server.ts. The .server suffix keeps it out of the client bundle,
which matters: this module reaches for cookie signing and node builtins.
// app/vinum.server.ts
import { denoKvStore } from "@vinumcms/adapters/deno-kv";
import { fsStore } from "@vinumcms/adapters/fs";
import { fsMedia } from "@vinumcms/adapters/fs-media";
import { registerVinum } from "@vinumcms/admin";
import { defineVinum, env } from "@vinumcms/server";
// Deno KV under Deno, a JSON file under Node. Both survive a restart.
const store = (globalThis as { Deno?: unknown }).Deno
? denoKvStore({ path: env("DENO_KV_PATH") ?? "./data/kv.sqlite" })
: fsStore({ path: "./data/store.json" });
export const vinum = registerVinum(
defineVinum({
site: { name: "My Site", url: env("SITE_URL") },
store,
media: fsMedia({ dir: "./data/media" }),
auth: {
cookieName: "mysite_admin",
secret: env("SESSION_SECRET"),
adminEmail: env("ADMIN_EMAIL"), // bootstraps the first account
adminPassword: env("ADMIN_PASSWORD"),
},
}),
);Not memoryStore() — it forgets everything when the dev server reloads, and
a second process (vinumcms mcp) opens its own empty database rather than
seeing yours. fsStore is one JSON file with a lock, so those two can write the
same content without losing each other's edits. It rewrites the whole file per
write, which is right for hundreds of pages and wrong for tens of thousands; at
that size use Deno KV or write a Store — five methods, and
@vinumcms/adapters/conformance will check it.
The file holds every user record, password hashes included. Keep data/ out of
git and out of any served directory.
3. ⚠️ Import that module from somewhere
// app/entry.server.tsx — first line
import "./vinum.server";This is the step everyone misses. registerVinum() is a module side effect:
it sets a value the mounted admin routes read back. Nothing in your app imports
vinum.server.ts on its own, so without this line the call never runs and every
admin route throws No Vinum instance registered at the first request — an
error that names a call you have already written.
No app/entry.server.tsx? On React Router v7, npx react-router reveal creates
it. On v8 that command is gone; copy
node_modules/@react-router/dev/dist/config/defaults/entry.server.node.tsx.
The alternative, if you prefer it: import vinum in app/root.tsx and use it
inside the loader. That works because React Router strips server code from
loader. A bare top-level import "./vinum.server" in root.tsx does not
work — it builds in dev and fails the production build with "Server-only module
referenced by client".
4. Mount the routes
// app/routes.ts
import { vinumRoutes } from "@vinumcms/admin/routes";
export default [
index("routes/home.tsx"),
...vinumRoutes(),
] satisfies RouteConfig;Your static routes go first — React Router ranks static above dynamic. Vinum's catch-all resolves stored redirects before 404ing, so you do not need one.
5. ⚠️ Let Vite compile the packages
// vite.config.ts
ssr: { noExternal: [/^@vinumcms\//] },Vinum ships TypeScript with no build step — exports point straight at .ts.
Without this, the SSR build hands Node raw TypeScript and you get a syntax error
deep inside node_modules that reads like a broken package.
6. ⚠️ Point Tailwind at the packages
/* app/app.css */
@import "tailwindcss";
@import "@vinumcms/react/base.css"; /* theme defaults + body wiring */
@source "../node_modules/@vinumcms/react/src";
@source "../node_modules/@vinumcms/admin/src";base.css is optional but recommended: Vinum styles what it renders, and body
is yours, so without it nothing applies --vinum-bg or --vinum-font-body to
the page itself. Everything it declares sits in @layer base, so your own
:root block wins regardless of import order.
Tailwind v4 does not scan node_modules. Without these, none of Vinum's classes
are generated and the admin renders completely unstyled. Nothing errors.
7. ⚠️ Let TypeScript read the packages
// tsconfig.json, under compilerOptions
"allowImportingTsExtensions": trueSame root cause as step 5: the shipped source uses extension-explicit imports.
Without this, tsc reports ~30 TS5097s from inside node_modules. It breaks
neither dev nor build, because Vite never typechecks — so it surfaces late,
usually in CI.
8. Set the theme
Nineteen variables style the public renderer and the whole admin. See
Theming below. If you imported base.css you can set as few as you
like and the rest fall back to neutral defaults.
9. A page at /
A page you create in the admin is a custom page and lives at /<slug>.
There is no slug for the site root, so a home page is declared by the host
instead — seeded once, with a route the host fixes:
// app/seed.ts
import type { Page } from "@vinumcms/core";
export const HOME: Page = {
slug: "home",
route: "/", // the host owns this path
kind: "system", // built in, undeletable, route not derived from the slug
title: "Home",
status: "published",
seoTitle: "",
seoDescription: "",
order: 0,
blocks: [],
};
// app/vinum.server.ts
defineVinum({ seed: { pages: [HOME] }, /* … */ });// app/routes.ts — the host route that serves it
index("routes/home.tsx"),
...vinumRoutes(),…whose loader is loadCmsPage(request, "home").
Why it matters beyond tidiness. kind: "system" is what tells Vinum the
page's real URL is /. Without it the page is custom, its route is /home, and
that is what goes in the sitemap and the canonical — so the site root advertises
some other URL as the original. Editors also cannot delete or re-slug a system
page, which is usually what you want for a home page.
Seeding runs only when the store is empty, so it establishes the page and then gets out of the way; everything after that is edited in the admin.
10. Environment
# .env — and add .env to .gitignore
SESSION_SECRET=... # required in production; a dev default is used if unset
[email protected]
ADMIN_PASSWORD=...
SITE_URL=http://localhost:5173Generate a secret with node -e "console.log(crypto.randomUUID()+crypto.randomUUID())"
— never Math.random.
If you build a container, exclude .env and your data directory.
create-react-router ships a .dockerignore of four lines — .react-router,
build, node_modules, README.md — and neither is on it. A COPY . . in a
build stage therefore bakes your session secret, your bootstrap admin password
and your entire database (including user password hashes) into a layer:
# .dockerignore
.env
.env.*
dataA multi-stage build usually does not copy those forward, so the final image is clean and the problem is invisible — but a build layer is still a layer, and layers get pushed and cached. The scaffold's default is reasonable for an app with no secrets and no database; installing Vinum is the moment it stops being one, which is why this warning lives here rather than in React Router's docs.
11. Check it, then sign in
npx vinumcms doctor # verifies steps 3, 5, 6, 7 and 10
npm run dev # then sign in at /adminTraffic
Optional, and off until you ask for it.
// app/vinum.server.ts
defineVinum({ analytics: { enabled: true }, /* … */ });Off until you ask for it. Page views only — no cookie, no visitor identifier,
nothing sent anywhere — with the report at /admin/traffic. Full detail,
including why it is built the way it is and what it deliberately cannot tell
you: docs/ANALYTICS.md.
A cookie banner ships beside it — consent: { enabled: true }, three categories,
wording editable per language at /admin/consent. It does not gate the
traffic counting above, which needs no consent: docs/CONSENT.md.
Theming
Nineteen CSS variables, set once on :root. Blocks and admin chrome read them;
an editor never picks a colour. This is the whole contract — there is no theme
object and no config key.
What an editor can pick is a variant: a named role, not a value. Blocks
that opt in offer tone — default, muted, accent, invert — which selects
one of the background/foreground pairs below, so a long page can be banded into
sections without anyone reaching for a hex code. The theme still supplies every
colour, and restyling it restyles all four. See
@vinumcms/react for the axis
and how to declare it on your own blocks.
Import @vinumcms/react/base.css and every one of them has a neutral default,
so you can override as few as you want.
:root {
/* surfaces */
--vinum-bg: #ffffff;
--vinum-bg-raised: #f6f6f5;
--vinum-bg-invert: #16171a;
/* text */
--vinum-fg: #16171a;
--vinum-fg-dim: #5c5f66;
--vinum-fg-faint: rgba(22, 23, 26, 0.12);
--vinum-fg-invert: #f2f2f0;
--vinum-fg-invert-dim: rgba(242, 242, 240, 0.62);
/* accent */
--vinum-accent: #7a5cff;
--vinum-accent-soft: #9b85ff;
--vinum-accent-invert: #9b85ff; /* the accent, on the inverted surface */
--vinum-on-accent: #ffffff; /* text on an accent fill */
/* lines */
--vinum-border: rgba(22, 23, 26, 0.14);
--vinum-border-strong: rgba(22, 23, 26, 0.38);
/* type */
--vinum-font-display: ui-sans-serif, system-ui, sans-serif;
--vinum-font-body: ui-sans-serif, system-ui, sans-serif;
--vinum-font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
--vinum-font-serif: ui-serif, Georgia, serif;
/* layout */
--vinum-measure: 1200px;
}VINUM_TOKENS exports the names and VINUM_DEFAULT_THEME the block above, both
from @vinumcms/react, so a host can render or validate them. A test keeps them
in step with base.css.
Full contract, including what each variable is for, the tone variant axis and
how to adopt an existing palette: docs/THEMING.md.
Forms
Build one at /admin/forms, drop it on a page with the form block. The builder
has three panes: a canvas showing the real layout, an inspector for the
selected field, and a palette you drag from — or click, which appends.
Alt+← / Alt+→ move a focused field, so it works without a pointer.
Fields carry their own width (full, half, third) and collapse to full below
the sm breakpoint. Sections, dividers and page breaks structure a form;
conditions show a field only when an earlier answer matches; validation runs on
the server through the same function the browser calls, so instant feedback
cannot disagree with the decision. Submissions are listed at
/admin/forms/<id>/submissions with CSV export, and the public endpoint is
POST /api/forms/<id>, mounted by vinumRoutes().
The rules that bite — why only earlier fields are offered as conditions, why hidden fields are neither validated nor stored, and why a form built before layout existed needs no migration: docs/FORMS.md.
This package's API
// app/routes.ts — config-time entry point
import { vinumRoutes } from "@vinumcms/admin/routes";
// anywhere else — runtime entry point
import { loadCmsPage, registerVinum } from "@vinumcms/admin";Two entry points, on purpose. @vinumcms/admin/routes runs at config time and
imports node:path; re-exporting it from the root would put node builtins in the
module graph of anything importing the UI kit, and the client build would fail on
fileURLToPath. A test enforces the split.
loadCmsPage(request, slug) is the loader for a host-owned page route: it
fetches, applies the isLive gate, resolves a stored redirect before 404ing, and
surfaces the origin for meta(). Vinum's own /:slug route uses it too.
Route modules live inside this package; vinumRoutes() computes the path from
the host's app directory to them.
vinumRoutes({ localePrefixes }) publishes in more than one language. The locale
rides in the slug (["page", "nl/about"]), never in a new key segment, so a
single-locale site can add languages without changing any URL it already has.
The other packages
@vinumcms/core ·
@vinumcms/server ·
@vinumcms/react ·
@vinumcms/adapters ·
@vinumcms/mail ·
vinumcms (the CLI)
Licence
MIT
