Your content, on the edge. EdgeCMS is a headless content management system that runs entirely on Cloudflare Workers — no origin servers, no cold starts, no nonsense. Ship i18n, media, and structured content blocks from the fastest infrastructure on the planet.
- Zero-origin architecture — D1 for storage, KV for caching, R2 for media. Everything runs at the edge.
- Drop-in ready — Deploy alongside your existing Cloudflare Workers app. All
routes live under
/edge-cms. - Type-safe SDK — Pull translations, generate TypeScript types, and import content blocks from the CLI. Your IDE stays happy.
- Version control for content — Draft, publish, rollback. Treat your content like code.
- AI-powered translations — Auto-translate missing keys with OpenAI. Ship faster in every language.
- Multi-language support with fallback to default locale
- Inline editing with auto-save — no submit buttons, no friction
- Section-based organization for large translation sets
- Stale detection — change a default-locale value and every translation written against the old one is flagged for review
- Draft/live versioning with publish and rollback
- AI-powered auto-translation, for untranslated keys alone or for outdated ones as well — your call at the point of running it
- Cached public API endpoints for blazing-fast delivery
- Define block schemas with typed properties (string, number, boolean, translation, media, block, collection)
- Create singleton or multi-instance collections
- Nest blocks within blocks for complex content structures
- Full versioning support — draft, publish, rollback
- Bulk import via CLI for migration workflows
- Upload files to R2 with automatic kebab-case sanitization
- Section-based organization
- Direct streaming from R2 — no intermediary processing
- Media state tracking with draft/live versioning
- Email/password auth powered by Better Auth
- Admin role management with protected routes
- API key support for programmatic access
- Per-key rate limiting (default: 1000 req/hour)
- Usage tracking with last-request timestamps
- Draft and live content states
- Publish drafts with Cloudflare Workflows
- Rollback to any previous version instantly
- Version descriptions for change tracking
| Layer | Technology |
|---|---|
| Framework | React Router v7 |
| UI | Tailwind CSS 4 + shadcn/ui |
| Database | Cloudflare D1 (SQLite) |
| ORM | Drizzle |
| Cache | Cloudflare KV |
| Storage | Cloudflare R2 |
| Auth | Better Auth |
| AI | OpenAI (via AI SDK) |
| Workflows | Cloudflare Workflows |
| Runtime | Cloudflare Workers |
npm installCreate a .dev.vars file for local development:
AUTH_SECRET=your-secret-key-here
ADMIN_SIGNUP_PASSWORD=your-admin-signup-secret
OPENAI_API_KEY=your-openai-api-key # Optional — for AI translationsFor production, set these as Cloudflare secrets:
npx wrangler secret put AUTH_SECRET
npx wrangler secret put ADMIN_SIGNUP_PASSWORD
npx wrangler secret put OPENAI_API_KEYYour wrangler.jsonc needs the following bindings:
# Local
npx wrangler d1 migrations apply edgecms-db --local
# Production
npx wrangler d1 migrations apply edgecms-dbnpm run typechecknpm run devnpm test # CMS + SDK
npm run test:watch # Watch mode (CMS)
npm run test:sdk # CLI/SDK onlyTests run inside the Workers runtime via @cloudflare/vitest-pool-workers,
against a real D1 database with the project's migrations applied, real R2 and KV
bindings, and real Workflows. API-key tests issue genuine better-auth keys, and
ordinary content publish tests run ReleaseVersionWorkflow through to
completion before asserting on its D1 rows and R2 objects.
The CLI in packages/sdk is plain Node code, so it is tested separately in a
Node environment (npm run test:sdk). Those tests stub fetch — the one
boundary they cannot cross — and drive the real commands against an in-memory
CMS that enforces the rules the CLI has to plan around.
Note: each real release logs an unhandled
TypeError: The RPC receiver does not implement the method "entries" from
miniflare's Workflows engine. It has no frame in project code, fires once per
run regardless of step count, and does not affect the outcome — the assertions
confirm every step's effect landed.
npm run deployThe @upbeat-works/edgecms-sdk package gives you a CLI and programmatic API to
interact with EdgeCMS from your codebase.
npm install @upbeat-works/edgecms-sdkCreate an edgecms.config.json in your project root:
{
"localesDir": "./src/locales",
"defaultLocale": "en",
"typesOutputPath": "./src/locales/types.ts",
"baseUrl": "${EDGECMS_BASE_URL}"
}Set your API key as an environment variable:
export EDGECMS_API_KEY=your-api-key
export EDGECMS_BASE_URL=https://your-domain.com/edge-cmsEach file has a stable asset ID, and each replacement creates a new revision under that asset. Blocks keep the asset ID, so they resolve the current media revision without needing a block release.
edgecms media --search hero # List/search current media
edgecms media --all-versions # Include archived revisions
edgecms media:upload ./hero.png --section home
edgecms media:replace 42 ./hero-new.png
edgecms media:rename 42 homepage-hero.png
edgecms blocks:set-media heroes 7 image 43 # Saved in the shared draftThe upload, replace, and rename commands print the asset ID, current revision,
state, and filename URL. Renaming moves every stored revision to the new
filename while preserving the asset ID. Block attachment and media IDs supplied
to import-blocks change blocks, so those changes become live through
edgecms publish. Media upload, replacement, rename, archive, and deletion take
effect at once.
Pull translations and generate TypeScript types.
edgecms pull # Pull live translations for default locale
edgecms pull --from draft # Pull draft translations
edgecms pull --all # Pull all localesPull also writes .edgecms-state.json inside localesDir. This file records
the opaque revision of the default-locale catalogue that was downloaded. Commit
it with the locale snapshot so every checkout pushes from the same known base.
This generates a types file with full autocompletion:
// Auto-generated by @edgecms/sdk
export interface TranslationKeys {
'common.title': string;
'common.description': string;
'homepage.hero': string;
}
export type TranslationKey = keyof TranslationKeys;
export function t(key: TranslationKey): TranslationKey {
return key;
}Push local translations to EdgeCMS as a draft.
edgecms push # Push default locale translations
edgecms push --section "homepage" # Assign new keys to a sectionPush requires the state written by pull. If an editor or another CLI changes
the default-locale catalogue after that pull, the command fails before writing
anything. Preserve local edits, pull the draft, reconcile both sets of changes,
then push again. A successful push advances .edgecms-state.json to the new
revision.
push only ever adds keys. prune is the other half: it compares the CMS
against your local translations file and removes the keys that are no longer
there.
Deleting is destructive, so it never happens by accident:
- Dry run by default. Without
--yesthe CMS reports what would go and changes nothing. The report is produced by the same code path as the real run, so it says exactly what--yeswill do. - Block-owned keys are never deleted. Keys a block instance generates or points at come back under protected, whoever asks for them.
- An empty local file aborts the run. A broken build that produces no keys would otherwise mark the entire CMS as unused.
- Deletions land in the draft. Nothing disappears from the live site until
you
publish, and a release can be rolled back.
edgecms prune # Report the orphans, delete nothing
edgecms prune --verbose # List every orphan rather than a sample
edgecms prune --yes # Delete them from the draftThe comparison is against the draft — the state the deletion applies to — and
covers the keys the default locale holds. A key that exists only in a
non-default locale is invisible to the diff, so prune will not propose it;
remove those with keys:delete. If one CMS serves several apps, run prune
from the app that owns the keys, since another app's keys look unused from here.
$ edgecms prune
Comparing en.json (312 keys) against the CMS draft (340 keys)
28 keys exist in the CMS but not locally.
24 keys would be deleted:
home.hero.oldTitle
checkout.legacy.notice
... 22 more (--verbose to list all)
Protected, will not be deleted — 4 keys are owned by block instances:
blocks.hero.12.title
...
Nothing was deleted — this was a dry run. Re-run with --yes to delete these 24 keys.
Delete named keys, for when you know exactly which ones to remove. Same
protections as prune: dry run unless --yes, block-owned keys refused, draft
only.
edgecms keys:delete home.hero.oldTitle checkout.legacy.notice
edgecms keys:delete home.hero.oldTitle --yesDeclare your block schemas and collections in blocks.schema.json and apply
them. Schema names are kebab-case, property names camelCase — the API rejects
anything else rather than silently renaming it.
{
"schemas": {
"card": {
"heading": "translation",
"url": "string"
},
"hero": {
"title": "translation",
"image": { "type": "media", "description": "Background image" },
"cards": { "type": "collection", "refSchema": "card" }
}
},
"collections": {
"homepage-hero": { "schema": "hero", "singleton": true },
"features": { "schema": "card", "section": "home" }
}
}A property is either a type name or an object with type, and optionally
refSchema (required for block and collection types) and description. New
properties are appended in the order they appear; a block or collection
property may point at any schema in the file, including its own, whatever order
they are written in.
edgecms blocks:push # Apply ./blocks.schema.json
edgecms blocks:push ./cms/blocks.json # ...or another fileApplying is additive and idempotent: it creates schemas, properties and
collections that don't exist yet and leaves everything else alone. It never
deletes a property, retypes one, or rebinds a collection to another schema — it
fails with PROPERTY_CONFLICT / COLLECTION_CONFLICT instead, so content
already stored under a schema cannot be orphaned by a file edit. Re-running it
after a partial failure is safe.
What the document does keep in sync is the parts that carry no structure: a
property's description and a collection's section are applied when they
differ. Anything the document doesn't mention is left as the CMS has it, so
descriptions written by an editor survive a push that says nothing about them.
$ edgecms blocks:push
Applying /app/blocks.schema.json
+ schema card (2 properties)
~ schema hero (+1)
+ collection homepage-hero (singleton)
= collection features
Note: these are draft changes. Run `edgecms publish` to make them live.
Set "blocksFile" in edgecms.config.json to change the default path.
List what the CMS holds.
edgecms schemas # Schemas with their properties
edgecms blocks # Collections with their schema and item countBulk import block instances from a JSON file.
edgecms import-blocks ./data.json "hero-blocks"
edgecms import-blocks ./data.json "carousel" --locale "es"Manage locales. A fresh instance has none, and push / import-blocks reject
unknown locales — so this is the first command to run against a new CMS.
edgecms languages # List locales, marking the default
edgecms languages:add en # First locale created becomes the default
edgecms languages:add pt-BR # Added as non-default
edgecms languages:add es --default # Create and make default in one step
edgecms languages:set-default es # Promote an existing localeLocale tags are canonicalised to BCP-47 casing (EN-us becomes en-US), so the
same language can't be created twice under different spellings. The command
prints the tag that was actually created.
Manage the sections used to organize translations, media, and block collections.
edgecms sections # List sections
edgecms sections:add Homepage # Create a section
edgecms sections:assign-keys Homepage home.title home.subtitle
edgecms sections:assign-media Homepage 12 18 # IDs from `edgecms media`
edgecms sections:rename Homepage Marketing # Rename it and refile its content
edgecms sections:delete Marketing # Preview deletion
edgecms sections:delete Marketing --yes # Delete and leave content unsortedSection deletion is a dry run unless --yes is supplied. Deleting a section
does not delete its content; translations, media, and block collections assigned
to it become unsorted. Assignment requires an existing section and existing keys
or media IDs. If any requested resource is missing, nothing is assigned.
Release the current draft, making it live. Until you publish, nothing the CLI writes is visible on the public endpoints.
edgecms publish # Start a release and return immediately
edgecms publish --wait # Block until it finishes
edgecms publish --wait --timeout 120 # ...with a custom timeout in seconds
edgecms publish:status <publishId> # Check a release started earlier--wait exits non-zero if the release ends in any state other than complete.
Preconditions (an existing draft, a default language) are checked up front, so a
misconfigured CMS fails immediately rather than halfway through a release.
Report keys present in the default locale but missing or empty elsewhere. Exits non-zero when anything is missing, which makes it usable as a CI gate.
edgecms check # Every non-default locale
edgecms check --locale es # Just one
edgecms check --verbose # List every key rather than a sampleReport translations written against a default-locale value that has since
changed. The complement of check: these keys are translated, they just answer
an older question. Exits non-zero when anything is stale.
edgecms stale # Every non-default locale
edgecms stale --locale es # Just one
edgecms stale --verbose # List every key rather than a sampleNothing goes stale on its own — a translation is cleared the moment it is rewritten, or when an editor confirms it in the admin UI. Changing which locale is the default resets the tracking, since hashes recorded against the old default say nothing about the new one.
Push the default locale first when seeding a CMS: a translation pushed before the value it translates exists has nothing to record, and is reported stale until it is rewritten or confirmed.
import {
pull,
push,
prune,
deleteKeys,
pushBlocks,
listSchemas,
listCollections,
importBlocks,
addLanguage,
setDefaultLanguage,
listSections,
addSection,
renameSection,
assignKeysToSection,
assignMediaToSection,
removeSection,
publish,
check,
} from '@upbeat-works/edgecms-sdk';edgecms languages:add en # Create the default locale
edgecms languages:add es
edgecms blocks:push # Create the schemas and collections
edgecms push # Upload translations into the draft
edgecms prune # Report keys the codebase no longer uses
edgecms check # Fail the build if anything is untranslated
edgecms publish --wait # Go liveEvery command above is safe to re-run: languages:add, blocks:push and push
create what's missing and leave the rest alone, and prune only reports until
someone passes --yes.
| Route | Description |
|---|---|
/edge-cms/sign-in |
Authentication |
/edge-cms/sign-up |
Admin registration |
/edge-cms/i18n |
Translation management |
/edge-cms/i18n/versions |
Version management |
/edge-cms/blocks |
Block schema & collection mgmt |
/edge-cms/media |
Media upload & management |
/edge-cms/sections |
Section management |
/edge-cms/users |
User management |
/edge-cms/settings/api-keys |
API key management |
| Route | Description |
|---|---|
GET /edge-cms/public/i18n/:locale.json |
Translations for a locale (cached) |
GET /edge-cms/public/media/:filename |
Serve media files from R2 |
GET /edge-cms/public/blocks/:collection |
Block collection data |
| Route | Method | Description |
|---|---|---|
/edge-cms/api/i18n/pull |
GET | Fetch translations |
/edge-cms/api/i18n/push |
POST | Create/update translations |
/edge-cms/api/i18n/languages |
GET | List available languages |
/edge-cms/api/i18n/languages |
POST | Create a language |
/edge-cms/api/i18n/languages |
PATCH | Set the default language |
/edge-cms/api/i18n/missing |
GET | Report untranslated keys |
/edge-cms/api/i18n/stale |
GET | Report translations the source has outrun |
/edge-cms/api/i18n/keys |
DELETE | Delete translation keys (dry run by default) |
/edge-cms/api/sections |
GET | List sections |
/edge-cms/api/sections |
POST | Create a section |
/edge-cms/api/sections |
PUT | Assign existing i18n keys or media |
/edge-cms/api/sections |
PATCH | Rename a section |
/edge-cms/api/sections |
DELETE | Delete a section (dry run by default) |
/edge-cms/api/blocks/import |
POST | Bulk import blocks |
/edge-cms/api/blocks/schemas |
GET | List schemas and their properties |
/edge-cms/api/blocks/schemas |
POST | Create a schema, or add missing properties |
/edge-cms/api/blocks/collections |
GET | List collections |
/edge-cms/api/blocks/collections |
POST | Create a collection |
/edge-cms/api/publish |
POST | Release the draft (returns a publishId) |
/edge-cms/api/publish |
GET | Status of a release, via ?id=<publishId> |
Errors share a shape: { "error": "...", "code": "MACHINE_READABLE_CODE" }.
Workers in the same Cloudflare account can skip HTTP and API keys entirely by
binding to the EdgeCMSService RPC entrypoint. A service binding is already an
authenticated, account-private channel.
// consumer's wrangler.jsonc
"services": [
{ "binding": "EDGECMS", "service": "edgecms", "entrypoint": "EdgeCMSService" }
]// Reads — served from the live published snapshot
const translations = await env.EDGECMS.getTranslations('en');
const blocks = await env.EDGECMS.getBlocks('hero-blocks');
const media = await env.EDGECMS.getMedia('logo.png'); // { contentType, size, etag, body }
const { languages, defaultLocale } = await env.EDGECMS.getLanguages();
const draft = await env.EDGECMS.pullTranslations();
const missing = await env.EDGECMS.missingTranslations();
const stale = await env.EDGECMS.staleTranslations();
// Writes
await env.EDGECMS.createLanguage('pt-BR', { makeDefault: false });
await env.EDGECMS.setDefaultLanguage('pt-BR');
await env.EDGECMS.applyBlockSchema('hero', [
{ name: 'title', type: 'translation' },
]);
await env.EDGECMS.createBlockCollection({
name: 'homepage-hero',
schema: 'hero',
});
await env.EDGECMS.deleteTranslationKeys(['home.hero.oldTitle'], {
dryRun: false,
});
const { publishId } = await env.EDGECMS.publish();
const state = await env.EDGECMS.publishStatus(publishId);RPC methods throw on failure instead of returning status codes, with
error.name carrying the same code the REST API returns (LOCALE_EXISTS,
NO_DRAFT, NO_DEFAULT_LANGUAGE, COLLECTION_NOT_FOUND, …). Both surfaces
call the same service layer, so validation and preconditions cannot drift apart.
- Sign in at
/edge-cms/sign-in - Navigate to
/edge-cms/i18n - Add languages and sections as needed
- Add translation keys and edit inline — changes auto-save
- Use versions to publish drafts or rollback changes
Cells flagged in amber were translated from a default-locale value that has since changed. Rewrite one to clear the flag, or confirm it with the ⚠ button to keep the text as it stands.
AI Translate offers two scopes, because they are not the same decision:
| Scope | Covers |
|---|---|
| Untranslated keys | Keys a locale never answered, or answered with an empty value |
| Untranslated and outdated keys | The above, plus translations whose source text changed — overwriting them |
The second scope replaces existing translations, including ones written by hand, so it is never the default and never implied.
const response = await fetch('/edge-cms/public/i18n/en.json');
const translations = await response.json();Or use the SDK for type-safe access:
edgecms pull- Navigate to
/edge-cms/blocks - Create a block schema with typed properties
- Create a collection (singleton or multi-instance)
- Add block instances with content
- Publish when ready
const response = await fetch('/edge-cms/public/blocks/hero');
const { items } = await response.json();- Navigate to
/edge-cms/media - Upload files — they're automatically sanitized to kebab-case
- Organize with sections
- Use the filename URL returned by the media API:
<img src="/edge-cms/public/media/my-image.jpg" alt="My Image" />- Go to
/edge-cms/settings/api-keys - Create a key with a descriptive name
- Set custom rate limits if needed
- Use the key in
EDGECMS_API_KEYfor SDK access
| Field | Description |
|---|---|
locale |
Language code (e.g., en, es) |
default |
Whether this is the fallback language |
| Field | Description |
|---|---|
name |
Section identifier for grouping content |
| Field | Description |
|---|---|
key |
Translation key |
language |
Language code |
value |
Translated text |
sourceHash |
Fingerprint of the default-locale value this was written from |
section |
Optional section reference |
state |
draft or live |
version |
Version number |
A translation is stale when its sourceHash no longer matches the one the
default-locale row carries. Only the row being edited is ever written, so
changing a default value costs one write no matter how many locales it
invalidates.
| Field | Description |
|---|---|
filename |
Sanitized filename |
mimeType |
File MIME type |
sizeBytes |
File size |
section |
Optional section reference |
state |
draft or live |
version |
Version number |
| Field | Description |
|---|---|
name |
Schema identifier |
type |
Property types: string, number, boolean, translation, media, block, collection |
| Field | Description |
|---|---|
name |
Collection identifier |
schema |
Associated block schema |
type |
singleton or collection |
| Field | Description |
|---|---|
collection |
Parent collection |
values |
Property values matching the schema |
state |
draft or live |
version |
Version number |
EdgeCMS is designed to run alongside your existing Cloudflare Workers app. Mount
it under /edge-cms and you're good to go — your CMS lives where your code
does, on the edge.
EdgeCMS supports project-specific admin pages through a small extension API.
Register custom routes under /edge-cms/custom/* and add links to the header
nav by editing app/extension.ts. Extension routes are mounted inside the
EdgeCMS layout, so they inherit auth, theme, and styling automatically.
See docs/extensions.md for the full guide.
See LICENSE.md.
{ // D1 Database "d1_databases": [ { "binding": "DB", "database_name": "edgecms-db", "database_id": "<your-database-id>", "migrations_dir": "./migrations", }, ], // KV Cache "kv_namespaces": [ { "binding": "CACHE", "id": "<your-kv-namespace-id>", }, ], // R2 Storage "r2_buckets": [ { "binding": "MEDIA_BUCKET", "bucket_name": "edgecms-media" }, { "binding": "BACKUPS_BUCKET", "bucket_name": "edgecms-backups" }, ], // Workflows "workflows": [ { "name": "edgecms-release-version-workflow", "binding": "RELEASE_VERSION_WORKFLOW", "class_name": "ReleaseVersionWorkflow", }, { "name": "edgecms-rollback-version-workflow", "binding": "ROLLBACK_VERSION_WORKFLOW", "class_name": "RollbackVersionWorkflow", }, { "name": "edgecms-ai-translate-workflow", "binding": "AI_TRANSLATE_WORKFLOW", "class_name": "AITranslateWorkflow", }, ], // Environment "vars": { "BASE_URL": "https://your-domain.com", "TRUSTED_ORIGINS": "https://your-domain.com", }, }