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.

One mental model: one draft = one branch = one PR = one version. Approval is a GitHub label. History is 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.

Host prerequisite: for the React hosts, Tailwind v4 (the editor chrome is styled with v4 utilities + 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>.

FrameworkStatusWhat the adapter mounts
Next.js (App Router)shippedFull copy-in: route handlers, login page, RSC layout wiring.
React Router 7shippedOne resource route → handleCmsRequest; root-loader session.
TanStack StartshippedOne server route → handleCmsRequest; head() meta. Modern Node.
Vite + React SPAshippedDOM-default client + Hono companion (createCmsApi); boot-time session.
Astro (React islands)shippedOne endpoint → createCmsApi; editor-gated island hydration.
Next.js Pages RoutercommunityA small (req,res) shim over the neutral handlers — seam published.
SvelteKit (Svelte 5)shippedNative Svelte 5 UI over the shared core + handlers. One catch-all +server.tscreateCmsApi; SSR.
Nuxt (Vue 3)shippedNative 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/actionhandleCmsRequest(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 routehandleCmsRequest(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.

Caveat: TanStack Start (Vite 7 toolchain) requires a modern Node — Node 20.19+ or 22.12+. Older Node won't build the example.

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.

Caveats (no SSR): a 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 endpointexport 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.

Editor-gated hydration: public visitors and crawlers always get static, fully server-rendered HTML with zero CMS JavaScript — the content is in the markup, so this is SEO-equivalent to a static page (unlike the Vite SPA). The hydrated React island — where editing happens — is served only to a logged-in editor.

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 endpointsrc/routes/api/cms/[...path]/+server.tscreateCmsApi(); +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 routeserver/routes/api/cms/[...path].tsdefineEventHandler(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.

One protocol, three UIs. React was the first editing client, Svelte the second, Vue the third — all over the same dispatcher, session, content model, and GitHub backend. The contract they build against is documented in docs/PROTOCOL.md.

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
Gotcha: bcrypt hashes contain $, 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.

PrimitiveFor
<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 */] },
});
KeyWhat it controls
namespaceCookie name + localStorage/IndexedDB keys, so two CMSBar sites never collide.
mediaFoldersWhere uploads, deletes, and folder creation are allowed; first is the default.
sharedPrefixesPaths that render on many pages — shown with the amber “shared” warning.
pagesStatic pages: draft titles + the per-page SEO drawer.
approvedLabelGitHub 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.
tourOptional 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.

Keep the schema type and the JSON in the same commit. A merged CMS PR redeploys with the new content compiled in.

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.

Try all of this in the live playground →

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.

Review-first on purpose: direct mode trades a reviewer for speed. Use it where a bad save costs a redeploy, not a client.

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 own GITHUB_TOKEN for 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 generated CMS_SESSION_SECRET + .env block, then it pushes a cmsbar/install branch and opens a PR with cms.config.ts, .env.example, and INSTALL-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 modenpm run seed:demo creates 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.

Billed on projects × editor seats (when billing lands). The dashboard is the only part of CMSBar behind a login — the docs and code are open.

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.

copied to clipboard