Documentation
Drop a Git-as-CMS into your codebase.
CMSBar copies into your repo (shadcn-style) — five React hosts (Next.js, React Router 7, TanStack Start, a Vite SPA, Astro) plus native SvelteKit and Nuxt UIs on the same protocol. Editors change content on the live site; each save is a branch and a pull request. No database, no hosted dashboard required, no lock-in.
What CMSBar is
A small set of components and API routes you install into your
project — five React hosts (Next.js, React Router 7, TanStack Start,
a Vite SPA, Astro) plus native SvelteKit (Svelte 5) and Nuxt (Vue 3)
UIs (see Frameworks).
You wrap editable content with primitives like
<T path="…"/>; logged-in editors click and edit
them in place. On Save, all changes commit as one commit on a
cms/* branch and open a GitHub PR. Merging redeploys with
the new content baked into the build.
git log.
Install
The CLI has two modes. Start a fresh site on any supported host:
# scaffold a host starter + assemble the neutral CMSBar core into it
npx cmsbar new my-site --framework next # next | react-router | tanstack-start | vite | astro | sveltekit | nuxt
Or add CMSBar to an existing project (defaults to Next):
# copies components/cmsbar, lib/cmsbar, a config, a theme (+ Next route handlers)
npx cmsbar init --namespace mysite
npm i bcryptjs clsx tailwind-merge lucide-react # React hosts; SvelteKit/Nuxt only need bcryptjs
For Next, init is a full copy-in (route handlers, login
page) and detects src/ vs root layout. For the other
hosts it drops the neutral core + the host adapter glue and prints
the wiring steps (mount one API route, wrap the root). Either way it
writes a cms.config.ts and appends the env keys to
.env.example. Nothing depends on the CLI afterward —
the code is yours. The template, CLI, and per-host examples live at
github.com/cmsbar/cmsbar.
styles/cmsbar.css
variables); the SvelteKit / Nuxt native UIs ship plain CSS and need
no Tailwind. Either way: a GitHub repo + fine-scoped PAT. See
Frameworks for what each host needs.
Frameworks
CMSBar is a framework-neutral core (~64% of the code, shared
untouched) plus a thin per-host seam: one client host
(usePathname, navigate, Image,
apiBase — with DOM defaults) and one server mount. The
entire API is a single dispatcher — handleCmsRequest for
framework route handlers, createCmsApi() for a
fetch-style mount — wired as one catch-all route. The seam is frozen
and validated across all five React hosts; a 156-test handler-level
harness backs it.
Five React-family hosts ship today, each with a live example that
builds and runs in
examples/.
Beyond React, SvelteKit (Svelte 5) and Nuxt (Vue 3) ship as native
UIs on the same protocol — the neutral core + handlers stay, the
component layer is rebuilt natively, at full feature parity and
browser-verified. All seven scaffold with
npx cmsbar new <dir> --framework <fw>.
| Framework | Status | What the adapter mounts |
|---|---|---|
| Next.js (App Router) | shipped | Full copy-in: route handlers, login page, RSC layout wiring. |
| React Router 7 | shipped | One resource route → handleCmsRequest; root-loader session. |
| TanStack Start | shipped | One server route → handleCmsRequest; head() meta. Modern Node. |
| Vite + React SPA | shipped | DOM-default client + Hono companion (createCmsApi); boot-time session. |
| Astro (React islands) | shipped | One endpoint → createCmsApi; editor-gated island hydration. |
| Next.js Pages Router | community | A small (req,res) shim over the neutral handlers — seam published. |
| SvelteKit (Svelte 5) | shipped | Native Svelte 5 UI over the shared core + handlers. One catch-all +server.ts → createCmsApi; SSR. |
| Nuxt (Vue 3) | shipped | Native Vue 3 UI, same protocol. One Nitro catch-all route → createCmsApi; SSR. |
Next.js (App Router)
npx cmsbar new my-site --framework next
The reference host. Mounts the CMS API as route handlers under
app/api/cms/*, a login page, and RSC layout wiring that
computes initialCms server-side. The full wiring is in
examples/next.
React Router 7 (framework mode)
npx cmsbar new my-site --framework react-router
The whole API mounts as one resource route
(loader/action → handleCmsRequest(request));
the root loader reads the session cookie into initialCms,
nav uses useLocation/useNavigate, and route
meta is built from resolvePageMeta(). SSR, so
SEO and the teaser gate work as on Next. See
examples/react-router.
TanStack Start
npx cmsbar new my-site --framework tanstack-start
The API mounts as one splat server route →
handleCmsRequest(request) (plain routes, not server
functions); the root loader builds initialCms and page
meta comes from a route head(). SSR, so SEO and the
teaser gate work. See
examples/tanstack-start.
Vite + React SPA (+ Hono companion)
npx cmsbar new my-site --framework vite
A client-only SPA needs nowhere for the write token or session
signing to live, so this host ships a small companion server:
the client uses the HostProvider DOM defaults and fetches
its session on boot (GET /api/cms/session); the companion
(Hono) mounts the whole API in one line —
app.all("/api/cms/*", (c) => cms(c.req.raw)) over
createCmsApi() — and serves the built dist/
on the same origin so the signed cookie just works. See
examples/vite-spa.
curl / returns the static
index.html shell + the bundle script, so
SEO / pageMeta edits affect nothing a crawler sees
— the SEO drawer still writes metadata into the content PR (ready for
an SSR/SSG host) but is a no-op for crawlers here. There's no
teaser ("coming soon") gate (no server render to gate), and content
ships inside the JS bundle, so the page renders after the bundle boots.
The companion is one long-lived process, so the in-memory rate limiter
is fine.
Astro (React islands)
npx cmsbar new my-site --framework astro
The whole API is one catch-all endpoint —
export const ALL = ({ request }) => createCmsApi()(request).
The content region is authored once as a React component;
index.astro reads the editor cookie server-side and
applies client:load only when an editor is present.
See
examples/astro.
SvelteKit (native Svelte 5 UI)
npx cmsbar new my-site --framework sveltekit
The first non-React client: the framework-neutral core + handlers are
reused unchanged, and the whole editing UI (bar, primitives, drawers,
tour) is rebuilt natively in Svelte 5. The API mounts as
one catch-all endpoint —
src/routes/api/cms/[...path]/+server.ts →
createCmsApi(); +layout.server.ts seeds the
session from the cookie and +layout.svelte provides the
store + mounts <CmsBar/>. SSR, so SEO and the teaser
gate work. Plain CSS — no Tailwind. Feature parity with the React UI,
browser-verified. See
examples/sveltekit.
Nuxt (native Vue 3 UI)
npx cmsbar new my-site --framework nuxt
The Vue counterpart: same neutral core + handlers, the editing UI
rebuilt natively in Vue 3 (SFCs). The whole API mounts in
one Nitro catch-all route —
server/routes/api/cms/[...path].ts →
defineEventHandler(e => cms(toWebRequest(e))) over
createCmsApi(); a server-only plugin seeds the session
and app.vue provides the store + mounts
<CmsBar/>. SSR; plain CSS — no Tailwind. Feature
parity, browser-verified. See
examples/nuxt.
Add to an existing project
Already have one of these hosts? npx cmsbar init --framework <fw>
(all seven) drops the neutral core and prints the wiring: mount one
API route → handleCmsRequest / createCmsApi,
and wrap your root with the host + <ContentProvider> +
<CmsBar/>. Next is a full copy-in and detects your
layout automatically; the non-Next React hosts also get adapter glue;
SvelteKit / Nuxt get their native SFC UI copied in.
The non-React protocol contract is in docs/PROTOCOL.md, and every host has a runnable example.
Wire the layout
Wrap your root layout once so every page can resolve content. The
example below is Next.js; on the other hosts the same provider is
wired from the root loader (React Router / TanStack Start), the island
root (Astro / Vite SPA), or the native root —
+layout.svelte (SvelteKit) / app.vue (Nuxt)
— see Frameworks.
For Next.js (App Router):
// app/layout.tsx
import { cookies } from "next/headers";
import { getContent } from "@/lib/content";
import { SESSION_COOKIE } from "@/lib/cmsbar/keys";
import { verifySession } from "@/lib/cmsbar/session";
import { ContentProvider } from "@/components/cmsbar/ContentProvider";
import { CmsBar } from "@/components/cmsbar/CmsBar";
export default async function RootLayout({ children }) {
const session = verifySession((await cookies()).get(SESSION_COOKIE)?.value);
return (
<html><body>
<ContentProvider
content={getContent()}
initialCms={{ authenticated: !!session, user: session?.user, draft: session?.draft }}
>
{children}
<CmsBar />
</ContentProvider>
</body></html>
);
}
Import the theme once in app/globals.css:
@import "../../styles/cmsbar.css";
Environment
Fill these into .env.local (and your host's env panel):
CMS_USER=admin
# bcrypt hash — escape every $ as \$ (dotenv-expand mangles it otherwise)
CMS_PASSWORD_HASH=\$2b\$10\$...
CMS_SESSION_SECRET=# 32+ hex chars
GITHUB_TOKEN=github_pat_... # fine-grained: Contents + Pull requests (RW)
GITHUB_OWNER=me
GITHUB_REPO=my-site
GITHUB_BASE_BRANCH=main
$, which Next's
dotenv-expand treats as interpolation. Escape every $
as \$. Quotes don't help.
Primitives
Wrap content with these instead of hardcoding values. Each takes a dotted path into your content JSON.
| Primitive | For |
|---|---|
<T path/> | Inline text + a floating rich-text toolbar (bold/italic/underline, optional decor class, links). |
<RichText path/> | Multi-paragraph rich text (block-level editor). Output ships styled — .cmsbar-prose gives headings, lists, and links visible defaults you can override. |
<EditableImage path positionPath/> | Image with a media browser — folder tree, upload, delete — plus drag-to-reposition stored as a focal point (defaults to a sibling <path>__pos key). |
<EditableMedia path/> | Video / image / embed. YouTube, Instagram, Vimeo, and Google Maps links (or pasted embed code) normalize to the right iframe; images in media slots get the same focal-point repositioning. |
<EditableInfoList path/> | Repeatable rows — icon + label + value — with add / remove / drag-reorder. (The “Informacije” block.) |
<T path="home.hero.headline" as="h1" className="text-5xl" />
<RichText path="home.hero.subtitle" as="p" />
<EditableImage path="course.hero.image" positionPath="course.hero.pos" fill alt="" />
<EditableMedia path="course.media" />
<EditableInfoList path="course.info" />
When no editor is logged in, every primitive just renders the bundled value — they're additive, so you can make one section editable and leave the rest.
cms.config.ts
One typed file owns everything project-specific. The CMSBar code reads this and nothing else.
import { defineCmsConfig } from "./lib/cmsbar/config";
export const cmsConfig = defineCmsConfig({
namespace: "mysite", // cookie + storage key prefix
siteName: "My Site",
domain: "example.com",
contentFile: "content/site-content.json",
mediaFolders: ["public/images", "public/media"],
branchPrefix: "cms/",
approvedLabel: "cmsbar approved",
sharedPrefixes: ["site.", "nav.", "footer."],
pages: [{ key: "home", path: "/", label: "Home" }],
richText: { decorClass: "" }, // optional decorative span class
publishing: { mode: "review" }, // or "direct" — see Publishing modes
tour: { autoStart: true, steps: [/* see Guided tour */] },
});
| Key | What it controls |
|---|---|
namespace | Cookie name + localStorage/IndexedDB keys, so two CMSBar sites never collide. |
mediaFolders | Where uploads, deletes, and folder creation are allowed; first is the default. |
sharedPrefixes | Paths that render on many pages — shown with the amber “shared” warning. |
pages | Static pages: draft titles + the per-page SEO drawer. |
approvedLabel | GitHub label that locks a draft PR read-only. |
publishing.mode | "review" (default) — every draft is a PR. "direct" — Save commits straight to the base branch. |
tour | Optional onboarding steps for <CmsTour/>; configuring it adds the ✦ Guide pill to the bar. |
Theming
Every brand-colored piece of editor chrome reads a CSS variable. Override them after the import to re-brand — no component edits.
/* app/globals.css, after the cmsbar import */
:root {
--cmsbar-accent: #db2777; /* buttons, page-local rings */
--cmsbar-accent-strong: #be185d;
--cmsbar-shared: #fbbf24; /* shared-element highlight */
--cmsbar-info: #2563eb; /* selected states in drawers */
--cmsbar-prose-heading-weight: 700; /* rich-text output (.cmsbar-prose) */
--cmsbar-prose-link: var(--cmsbar-info);
}
Rich-text output is styled by .cmsbar-prose (heading
sizes, list markers, underlined links — so every toolbar action is
visible out of the box, even under Tailwind preflight). It's
declared in @layer base: any site rule of equal
specificity overrides it, layered or not.
Guided tour
Optional onboarding, defined entirely in cms.config.ts.
With tour configured, the bar gains a ✦ Guide
pill and <CmsTour/> walks editors through your
steps, spotlighting each step's target selector. Steps
without a target — or whose target isn't in the DOM yet — render as
a centered card, so write those bodies to tell the editor what to
click.
tour: {
autoStart: true, // open once per browser for authenticated editors
steps: [
{ id: "welcome", title: "Welcome", body: "…" }, // no target → centered card
{
id: "bar",
title: "The CMS bar",
body: "…",
target: "[data-cms-bar]", // CSS selector to spotlight
placement: "top", // default "bottom"
},
],
},
“Done” / “Skip tour” gate autoStart per browser
(localStorage); the current step survives the reload that “New
draft” causes. Re-open it any time from the bar — or from code:
window.dispatchEvent(new CustomEvent("cmsbar:tour:open")).
Content & schema
Content lives in the JSON file you set as contentFile.
The SiteContent type in lib/content.ts is
yours — infer it from the JSON, or write it explicitly for stricter
guarantees. Add a key to the JSON, reference it from a primitive's
path, done.
Editor workflow
- New draft — creates
cms/<slug>and opens[CMS draft] <title>. - Edit — click any outlined element; pink = page-local, amber = shared across pages.
- Save — one commit with all batched changes. Unsaved work survives reloads.
- Versions — every open
cms/*PR; preview, edit, or fork any of them. - Approve — a reviewer adds the approval label; the draft locks read-only.
- Merge — redeploys. The editor never touched Git.
Publishing modes
Review is the default: every draft is a PR, approval is a label, merge deploys. When a site doesn't need review — your own site, a prototype, a trusted editor — flip one key:
// cms.config.ts
publishing: {
mode: "direct", // default: "review"
},
In direct mode, Save commits straight to
GITHUB_BASE_BRANCH — no PR to merge, your deploy
webhook picks it up. Still one commit per Save, still full history
in git log. Flip back to "review"
whenever the stakes change.
Storage backend
GitHub is the reference adapter (Git Data API, one commit per Save,
fine-scoped PAT, stateless container). The
CmsBackend interface in
lib/cmsbar/backend/types.ts documents the seam for a
future GitLab / file / database adapter — route handlers consume the
interface, the adapter fills it.
Cloud Studio (optional)
Self-host everything for free. Cloud Studio is the hosted control plane for agencies who'd rather not operate it themselves — the Phase 9 MVP, in development (v0.2), with the OSS core fully functional without it. What runs today:
- Approval dashboard across every connected repo — all open
cms/*drafts, who's editing, last activity, and one-click Approve / Unapprove (toggles the approval label the instance honors) and Merge (squash → redeploy). - Per-project chips — a live install status (installed / install PR open / not installed, detected via the contents API) and the site's publishing mode (parsed from
cms.config.ts, no code evaluation). - Token custody — connect a repo with its fine-grained PAT; it's validated, then stored AES-256-GCM encrypted (
CLOUD_SECRET) in SQLite, never exposed to the browser. (The site still needs its ownGITHUB_TOKENfor now — custody doesn't hand tokens to instances yet.) - “Set up CMSBar” wizard (
/setup) — guided onboarding for repos that don't run CMSBar yet: pick the repo, enter site basics, choose the publishing mode, get a generatedCMS_SESSION_SECRET+.envblock, then it pushes acmsbar/installbranch and opens a PR withcms.config.ts,.env.example, andINSTALL-CMSBAR.md. The preview pane shows byte-for-byte what gets committed. - Magic-link sign-in for admins, plus an editor-seat data model so seat counting works from day one.
- Demo mode —
npm run seed:democreates two sample projects (one installed with canned drafts, one to walk the wizard) flagged so no GitHub calls and no real PAT are ever made.
Deliberately not built yet (no adoption signal): billing / Stripe; editor identity for site visitors (magic-link login for clients on the CMSBar sites themselves); a GitHub App (installation tokens); and preview deploys + media CDN + AI auto-wrap. Developed in the open at github.com/cmsbar/cloud; join the waitlist from the pricing section.
Troubleshooting
Login works but Save fails with a GitHub error
Check the PAT scopes (Contents + Pull requests, read & write) and that GITHUB_BASE_BRANCH points at the branch holding current content — a stale base makes saves diff against old content.
My password hash isn't accepted
Escape every $ in CMS_PASSWORD_HASH as \$. dotenv-expand silently mangles unescaped $.
Edits don't show after merging
Content is compiled into the build. Confirm your host redeploys on merge (webhook) and that the merge targeted GITHUB_BASE_BRANCH.
Which frameworks are supported?
Five React-family hosts ship today, each with a live example that builds and runs: Next.js (App Router), React Router 7, TanStack Start, a Vite + React SPA (with a small Hono companion server), and Astro (React islands, editor-gated hydration). SvelteKit (Svelte 5) and Nuxt (Vue 3) also ship as native UIs on the same protocol — so seven hosts scaffold with npx cmsbar new --framework <fw>. See the Frameworks section for per-host quickstarts and caveats, and docs/PROTOCOL.md for the non-React contract. A pure SPA needs the companion server for the write token — the token never lives in the browser.