npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 mailings

Name 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": true

Same 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:5173

Generate 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.*
data

A 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 /admin

Traffic

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 tonedefault, 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