Static-first visual editing for Astro sites. Free and open source (MIT).
⚠️ Early days (0.x). CharlesCMS is young and under active development — some things may be rough or not work on every site yet. Source edits are byte-verified and conservative by design, but please test on a branch first and report anything that breaks. APIs may change before 1.0.
Requires Node.js 22.12 or newer and Astro 6.2. No account, no service, no config.
npm install @charlescms/astro// astro.config.mjs
import { defineConfig } from "astro/config";
import charlesCMS from "@charlescms/astro";
export default defineConfig({ integrations: [charlesCMS()] });npm run devOpen /cms, click Start editing locally, then click any text or image on
the page to change it. No schema, no content models, no setup.
In npm run dev your changes are written straight to your real source files —
src/pages/index.astro, your Markdown, your data — byte-for-byte and conservative
by design. Press Save to local files, then commit with your own Git when you're
happy:
git add -A && git commit -m "Edit copy in CharlesCMS"That's the whole loop: click → edit → Save → git commit. No GitHub App, no
Cloudflare, nothing to deploy — this alone is a complete local visual editor.
Want non-technical people to edit the live site themselves (no local checkout, no Git)? That's the optional connector below — about two more minutes.
This deploys the connector, not your website. Your site stays hosted wherever you already put it (Vercel, Netlify, Cloudflare Pages, GitHub Pages…). The connector is a tiny extra Cloudflare Worker whose only job is to commit editors' changes to GitHub — then your existing host rebuilds the site as usual. The loop is: edit in
/cms→ connector commits to GitHub → your host redeploys → live.
Local editing needs nothing. To let people edit your live site — each save committed
to GitHub for you — deploy that connector. You need a GitHub repo and a free
Cloudflare account (it fits the Workers free tier). Nothing to install first — npx
fetches the tools; just say yes if it offers to install one.
One-time setup (~5 min). Do it in this order so you never have to come back:
- Put your site in a GitHub repo and deploy it to your host (push → it builds; any host works — see Deploy your site). You now have your site's URL.
npx wrangler login— opens a browser to sign in to Cloudflare.npx charlescms setup --deploy --open— answer the prompts; when it asks for your live site URL, paste the one from step 1 (and set an editor password). A browser opens to create the GitHub App (click create); it then deploys the Worker and prints your connector URL.- Click the install link it prints, to add the GitHub App to your repo.
- On your live site, open
/cms→ Connect a publisher → paste the connector URL, repo (owner/name), and password.
Now Edit → Publish → live — each Publish commits to GitHub and your host rebuilds.
Verify anytime: npx charlescms doctor <connector-url>.
Not deployed yet? You can still set up the connector — leave the live-site URL blank, finish, then add it later by re-running
npx charlescms setup --deployonce your site is live (it remembers your answers). Deploying first just saves that second trip.
Skip the form: bake the connection into
astro.configso editors land straight on Start editing (only the password is asked):charlesCMS({ connector: "https://<worker>.workers.dev", repo: "owner/name", branch: "main" })
Prefer clicking / no CLI? See Manual setup.
npx charlescms setup [--deploy] [--open] # scaffold the connector (and deploy)
npx charlescms doctor [connector-url] # check files + a live connector verify
npx charlescms --help # all commands and options
npx charlescms --versionsetup detects your repository's default branch automatically, so edits target
the right branch (main, master, …) without you having to pick. --open opens
the GitHub App page for you; --deploy also stores the Worker secrets and runs
wrangler deploy.
Publish commits to GitHub, so any host that rebuilds on push works — Cloudflare Pages, Netlify, Vercel, GitHub Pages, or your own CI.
Cloudflare keeps it simplest. The connector already runs on Cloudflare Workers,
so hosting the site on Cloudflare Pages keeps everything on one account and the
same wrangler CLI. Connect the repo in the Cloudflare dashboard — build command
npm run build, output directory dist — or push straight from the CLI:
npm run build
npx wrangler pages deploy distEach Publish commits to GitHub; Pages rebuilds automatically. Edit → Publish → live.
The site is a standard static Astro build (
npm run build→dist/), so any static host works — only the connector requires Cloudflare Workers. When you host elsewhere, add that site's URL to the connector'sALLOWED_ORIGINS.
Protect a public editor with one password — the Worker secret AUTH_SECRET.
Editors enter it once on /cms.
- Set / change: in the assistant,
npx wrangler secret put AUTH_SECRET, or the Cloudflare dashboard (Settings → Variables and Secrets). Takes effect immediately. - Rotate: replace it with
npx wrangler secret put AUTH_SECRET.
The connector fails closed: with no AUTH_SECRET set it refuses every edit
(503), so a misconfigured deploy can never publish openly — always set one. The
secret is sent as a header over HTTPS and compared in constant time; GitHub
credentials stay in the Worker (never in the site or localStorage).
It's a single shared editor identity (no per-user audit; revoke by rotating). For
teams / per-user auth on higher-value sites, put the Worker behind Cloudflare
Access, or add GitHub OAuth in the authorizeRequest() hook
(connector/worker.js), plus a Cloudflare rate-limit rule on /api/*.
By default the editor changes existing content in place. Optionally, let editors insert whole new sections between developer-approved children. Mark the wrapper:
<main
data-charlescms-sections
data-charlescms-section-class="prose section-shell"
data-charlescms-section-tools="header,paragraph,list,image,quote,delimiter"
>
<Hero />
<Features />
<Testimonials />
</main>That one attribute is the whole decision: sections can be inserted exactly where
the developer placed the wrapper, and nowhere else — pages without it never show
an insert control. The wrapper's direct children must be static markup (dynamic
.map() children are skipped).
data-charlescms-section-class— class(es) every inserted section receives, so new sections inherit the site's own spacing and look.data-charlescms-section-tools— which blocks editors may use (header,paragraph,list,image,quote,delimiter).
Offer branded templates as static snippets in src/charlescms/templates/
(recommended — you own the markup and classes). Every .html or .astro file
there becomes a choice in the picker; the filename is the label
(team-quote.html → “Team Quote”):
<!-- src/charlescms/templates/newsletter.html -->
<h2 class="display-lg">Stay in the loop</h2>
<p>Occasional notes from the studio.</p>
<a class="button" href="/newsletter">Subscribe</a>Templates must be pure static markup: files containing scripts, frontmatter,
{expressions} or components are skipped with a dev-server warning (a section is
inserted verbatim as page content, so nothing dynamic can ride along).
What the editor experiences:
- Add section here appears before, between, and after the wrapper's sections.
- Clicking it offers your templates; choosing one stages the section as a live draft on the page. Clicking the draft offers Remove section — nothing touches the repository until Publish.
- Publish commits the section as sanitized, static Astro source (wrapped in a
durable
data-charlescms-block). From then on it is ordinary page content: its text and images are edited right on the page, like everything else, and the whole section can be removed again at any time.
There is deliberately no free-form block builder: editors compose pages from sections you designed, and edit content in one consistent way — on the page.
Everything visible is edited by clicking it on the page. Page info in the toolbar holds the fields that render nowhere visible — the Google/browser-tab title, the meta description, and other frontmatter.
- Static text & headings, and safe inline rich text (bold, italic, inline code, links, line breaks)
- Markdown frontmatter and body — incl. Astro Content Collections (
.md,.mdx, and JSONtype: 'data'collections) - Data rendered with
.map()— arrays of objects and positional tuples ([["Name","Desc","8"], …]): names, descriptions, prices, list items - Config values (
site.phone, address, hours, …) and component props (hero titles, section eyebrows) — clickable on the page, edited safely at source - Navigation menus — labels edit; destinations stay locked
- Links & downloads — link text edits; PDFs/files replace in place
- Images, video, audio, embeds, iframes — upload, swap, bounded remove
- New sections (see above) and versions / undo via Git commits
Rich vs plain is decided by how your site renders a value: where it interprets
formatting (a Markdown body, set:html), you get full rich text; where it prints
raw text ({value}), it stays plain so formatting can never show as literal
characters. Pure logic — loops, conditions, computed expressions — is never
editable (that's code, not content); clicking such generated content shows a
calm "generated by the page, can't be edited here" hint instead of doing nothing.
Every write is verified against the exact source bytes and refused if the source
changed, so an edit can't silently corrupt surrounding code. Edited values are
escaped as content — including the {/} Astro reads as expressions — so typed
text stays text and can't be reinterpreted as code; a downstream astro build
remains the final check on any committed file.
CharlesCMS scans your source to know what's editable — automatically on save in dev, at build time in production (rebuild to pick up new content). It works on any Astro site, with or without a layout, alongside React/Svelte/Vue islands (those files are never touched).
charlesCMS({
// Connection (optional — bake in for one-click "Start editing"):
connector: "https://<your-worker>.workers.dev",
repo: "owner/name",
branch: "main",
sourceRoot: "website", // monorepos only
editablePaths: ["/demo", "/blog"], // limit where the editor activates
adminPath: "/cms", // change the editor route
})Monorepos: sourceRoot is the app folder holding the Astro project, so
src/pages/index.astro commits as website/src/pages/index.astro.
Skip the assistant and set up the Worker by hand:
-
Install + configure as in Quickstart.
-
Create the Worker:
mkdir -p connector cp node_modules/@charlescms/astro/connector/worker.js connector/worker.js
# connector/wrangler.toml name = "<your-worker>" main = "./worker.js" compatibility_date = "2026-06-04" [vars] ALLOWED_ORIGINS = "https://www.yoursite.com" # comma-separated; add http://localhost:4321 for dev ALLOWED_REPOS = "owner/name" DEFAULT_BRANCH = "main"
cd connector && npx wrangler login
-
Store secrets (GitHub App with
contents: write+metadata: read, installed on the repo) and the editor password:npx wrangler secret put GITHUB_APP_ID npx wrangler secret put GITHUB_PRIVATE_KEY npx wrangler secret put AUTH_SECRET # required — the connector refuses to act without it -
Deploy and verify:
npx wrangler deploy npx charlescms doctor https://<your-worker>.workers.dev
npx charlescms doctor <url> maps each failure to a fix:
| Symptom | Likely cause | Fix |
|---|---|---|
401 on publish or in doctor |
Missing/wrong editor password | Use the value matching the Worker's AUTH_SECRET (or set it with wrangler secret put AUTH_SECRET) |
403 "not allowed" |
Origin/repo not allow-listed | Fix ALLOWED_ORIGINS / ALLOWED_REPOS in wrangler.toml, redeploy |
403 from GitHub |
App lacks access, or stale Worker | Install/grant the GitHub App, then wrangler deploy |
404 |
GitHub App not installed on the repo | Install it (doctor prints the exact github.com/apps/<app>/installations/new link), or check the repo path |
The editor ships as static HTML/JS, reads a build-time source map, and stages edits
as a live preview. In npm run dev with no connector, Save applies the edit to
your local source file on disk through a dev-only endpoint — the same byte-verified
transform, no network. With a connector, Publish sends the exact source span to
the Worker, which commits to GitHub — credentials stay in the Worker, never in the
site or localStorage. Either way the file is re-read first and the edit refused if
the source changed, and rich text and section HTML are re-sanitized before the
complete updated file is written.
A path-traversal advisory (GHSA-g7r4-m6w7-qqqr)
affects esbuild's development server on Windows in versions before 0.28.1.
It is dev-only and Windows-only — it does not affect your built or published
site. Vite 7 still permits esbuild@0.27.x, so if you run astro dev on Windows,
pin the patched release in your site's root package.json and reinstall:
{ "overrides": { "esbuild": "^0.28.1" } }You can drop this once your installed Vite requires esbuild >= 0.28.1.
npm test
npm run test:coverage
npm run test:e2e
npm run buildGitHub Actions runs the coverage and browser suites on Linux plus the packed
package smoke test on ubuntu-latest, macos-latest, and windows-latest.
Each job installs the tarball in a clean Astro project, runs the CLI setup,
builds, starts the dev server, and checks both / and /cms.
The browser editor is split by responsibility:
src/client.jsis the composition root: shared state, module wiring, session helpers, and the direct Tiptap imports required for reliable Vitefile:installs.src/runtime-controller.js,src/source-map-runtime.js, andsrc/edit-affordance.jsown startup, source-map binding, and click/hover behavior.src/element-editor.js,src/rich-text-editor.js, andsrc/section-editor.jsown the three editing experiences.src/publishing.jsowns staging, publishing, uploads, replacements, and bounded media removal.src/content-panel.jsowns the Page info (frontmatter/SEO) and data forms;src/content-bridge.jsmaps rendered data to those deterministic forms.src/panel-manager.js,src/toolbar.js,src/versions-panel.js, andsrc/editor-styles.jsown the surrounding editor UI.src/analyzer.jshandles Astro markup whilesrc/data-analyzer.jshandles conservative JavaScript/TypeScript/JSON data collections. Both read source throughsrc/js-ast.js, a small layer over the Babel parser that locates string literals at exact offsets (any nesting depth) without evaluating code.
All source writes remain byte-verified in the shared source-editing layer; the DOM bridge only chooses which deterministic editor to open.
CharlesCMS is free and open source under the MIT License — use it for anything, including commercial work, no fees and no keys. Third-party dependency licences are summarized in THIRD_PARTY_NOTICES.md.
Everything self-hosts on your own Cloudflare and GitHub.