Inline-edit CMS engine
01 — Try it
There is no demo mode. This is the app.
Every keystroke you make on this page runs the same code path a production app runs: engine, transport, adapter, your database.
- Turn on edit mode. Every field on this page becomes editable in place, headings and body copy included.⌘E
- Click anything and type. No admin panel, no modal, no second URL to remember.
- Save every dirty field in one round trip. Volatile adapters skip this and save on blur.⌘S
- Open the database panel below. The row you just changed is sitting in Postgres.
- Reload. It is still there, because PGlite persists to IndexedDB, in your tab, with no server.
02 — Where it went
Your edit is a row. Here it is.
Content is rows, not commits. That is the difference between this and a git-backed CMS: it works for apps, not only static sites.
hero.headlinehero.taglinehero.introshared.messagecards[0].titlecards[1].title03 — Beyond text
Insert a row. Drag it. Delete it. Drop in an image.
The same engine that edits a field also creates, reorders and deletes records. Every op updates the snapshot immediately and rolls back if the adapter rejects the write. Turn on edit mode to get the controls.
04 — Bindings
Edit in React. Watch Vue and Svelte.
These are three real islands on one page, each a different framework, all subscribed to the same engine. Type into any of them and the other two re-render before the save even lands.
import { ContentEditSpan, usePageContext } from "better-content/react";
export default function NoteCard() {
const { hasUnsavedChanges } = usePageContext();
return (
<article className="island" data-framework="react">
<header className="island__bar">
<span className="island__dot"></span>
<span className="island__name">React</span>
<code className="island__hook"><ContentEditSpan /></code>
<span className="island__state" data-dirty={hasUnsavedChanges || undefined}>
{hasUnsavedChanges ? "unsaved" : "synced"}
</span>
</header>
<div className="island__body">
<p className="island__path">page / shared.message</p>
<ContentEditSpan
as="p"
className="island__text"
collection="page"
itemId="shared"
fieldKey="message"
/>
</div>
</article>
);
}<script setup lang="ts">
import type { CmsEngine } from "better-content/core";
import { useCmsSnapshot, vContentEdit } from "better-content/vue";
const props = defineProps<{ engine: CmsEngine; editing: boolean }>();
const snapshot = useCmsSnapshot(props.engine);
</script>
<template>
<article class="island" data-framework="vue">
<header class="island__bar">
<span class="island__dot"></span>
<span class="island__name">Vue</span>
<code class="island__hook">v-content-edit</code>
<span class="island__state" :data-dirty="snapshot.hasUnsavedChanges || undefined">
{{ snapshot.hasUnsavedChanges ? "unsaved" : "synced" }}
</span>
</header>
<div class="island__body">
<p class="island__path">page / shared.message</p>
<p
class="island__text"
v-content-edit="{
engine,
collection: 'page',
itemId: 'shared',
fieldKey: 'message',
editing,
}"
></p>
</div>
</article>
</template><script lang="ts">
import type { CmsEngine } from "better-content/core";
import { contentEdit, engineStore } from "better-content/svelte";
export let engine: CmsEngine;
export let editing: boolean;
const snapshot = engineStore(engine);
</script>
<article class="island" data-framework="svelte">
<header class="island__bar">
<span class="island__dot"></span>
<span class="island__name">Svelte</span>
<code class="island__hook">use:contentEdit</code>
<span class="island__state" data-dirty={$snapshot.hasUnsavedChanges || undefined}>
{$snapshot.hasUnsavedChanges ? "unsaved" : "synced"}
</span>
</header>
<div class="island__body">
<p class="island__path">page / shared.message</p>
<p
class="island__text"
use:contentEdit={{
engine,
collection: "page",
itemId: "shared",
fieldKey: "message",
editing,
}}
></p>
</div>
</article>05 — Adapters
Seven methods stand between us and your database.
There is no hosted service to sign up for and no proprietary backend to migrate off. Implement these seven and better-content talks to whatever you already run.
interface DataAdapter
fetchCollection(collection, q?)readfetchById(collection, id)readcreate(collection, data)writecreateWithId(collection, id, data)writeupdate(collection, id, patch)writeupsert(collection, id, patch)writedelete(collection, id)writeAn afternoon of work for a database you already know.
import {
adapterTransport,
createCmsEngine,
type ClientStorageAdapter,
type CmsEngine,
} from "better-content/core";
import { loadItemMap } from "better-content/server";
import { loadAdapter } from "./db";
import { statusStore } from "./status";
// Uploaded images become data URLs so the whole demo stays inside your tab.
// In a real app this would be a storage adapter like cloudinary.
const dataUrlStorage: ClientStorageAdapter = {
upload: (file) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve({ url: reader.result as string });
reader.onerror = () => reject(new Error("Could not read file"));
reader.readAsDataURL(file);
}),
};
// ONE engine for the whole page. Every island below, whatever its
// framework, binds to this exact object. The engine itself never
// imports React, Vue, or Svelte.
export const ready: Promise<CmsEngine> = (async () => {
const adapter = await loadAdapter();
const initialItems = await loadItemMap(adapter, {
page: {},
cards: { query: { orderBy: [{ field: "order", direction: "asc" }] } },
});
return createCmsEngine({
transport: adapterTransport(adapter),
storage: dataUrlStorage,
notify: { success: statusStore.set, error: statusStore.set },
initialItems,
});
})();import { PGlite } from "@electric-sql/pglite";
import { drizzle } from "drizzle-orm/pglite";
import { integer, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { PostgresDataAdapter } from "better-content/adapters/postgres";
const page = pgTable("page", {
id: text("id").primaryKey(),
headline: text("headline"),
intro: text("intro"),
cover: text("cover"),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
});
const cards = pgTable("cards", {
id: text("id").primaryKey(),
title: text("title"),
body: text("body"),
order: integer("order"),
});
const schema = { page, cards };
const client = new PGlite("idb://better-content-playground");
export const adapter = new PostgresDataAdapter({
db: drizzle(client, { schema }) as never,
schema: schema as never,
});import type { DataAdapter, Item, Query } from "better-content/core";
const data = new Map<string, Map<string, Item>>();
const table = (collection: string) => {
let rows = data.get(collection);
if (!rows) {
rows = new Map();
data.set(collection, rows);
}
return rows;
};
const sortRows = (rows: Item[], query?: Query) => {
const order = query?.orderBy?.[0];
if (!order) return rows;
return rows.sort((a, b) =>
((a[order.field] as number) ?? 0) - ((b[order.field] as number) ?? 0),
);
};
export const adapter: DataAdapter = {
async fetchCollection<T>(collection: string, query?: Query) {
const rows = sortRows([...table(collection).values()], query);
return rows as (T & { id: string })[];
},
async fetchById<T>(collection: string, id: string) {
return (table(collection).get(id) ?? null) as (T & { id: string }) | null;
},
async create(collection, fields) {
const item = { id: crypto.randomUUID(), ...fields };
table(collection).set(item.id, item);
return item;
},
async createWithId(collection, id, fields) {
// The contract: createWithId rejects an id that already exists.
// upsert is the write that does not care.
if (table(collection).has(id)) throw new Error(`${id} already exists`);
const item = { id, ...fields };
table(collection).set(id, item);
return item;
},
async update(collection, id, patch) {
table(collection).set(id, { ...table(collection).get(id), ...patch, id });
},
async upsert(collection, id, patch) {
table(collection).set(id, { ...table(collection).get(id), ...patch, id });
},
async delete(collection, id) {
table(collection).delete(id);
},
};import type { DataAdapter } from "better-content/core";
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
const response = await fetch(`/api/content${path}`, {
headers: { "content-type": "application/json" },
...init,
});
if (!response.ok) throw new Error(`Request failed: ${response.status}`);
return response.json() as Promise<T>;
};
export const adapter: DataAdapter = {
fetchCollection(collection, query) {
const params = new URLSearchParams({
query: JSON.stringify(query ?? {}),
});
return request(`/${collection}?${params}`);
},
fetchById(collection, id) {
return request(`/${collection}/${id}`);
},
create(collection, data) {
return request(`/${collection}`, {
method: "POST",
body: JSON.stringify(data),
});
},
createWithId(collection, id, data) {
return request(`/${collection}/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
},
update(collection, id, patch) {
return request(`/${collection}/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
});
},
upsert(collection, id, data) {
return request(`/${collection}/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
},
delete(collection, id) {
return request(`/${collection}/${id}`, { method: "DELETE" });
},
};Take it home.
Scaffold a Next.js, Nuxt, SvelteKit or Astro app wired to Postgres or Firestore. MIT licensed, pre-1.0, no account required.