Skip to content

Repository files navigation

better-content — a page of text with one line highlighted by an editor's marker

better-content

CI npm

Own-your-data, adapter-driven, framework-agnostic inline-edit CMS engine. MIT.

Documentation · Live demo: edit the page in your browser, every field persists to a real Postgres running in your tab.

Demo: switching the binding between React, Vue, and Svelte and the adapter between PGlite, in-memory, and REST, then editing the page, inserting a row, and watching both land in the database

Edit content directly on your live pages. Changes persist to your database through a small adapter seam, with no hosted service, no lock-in, and no proprietary backend. The engine is a framework-free external store with React, Vue, and Svelte bindings, and one engine can serve all three at once (e.g. Astro islands).

Status: pre-1.0. The API is still settling; minor versions may break until 1.0. Two adapters (Postgres, Firestore), one storage provider (Cloudinary), and one auth provider (Firebase) ship as proof of the seams.

Why

  • Own your data — content lives in your Postgres/Firestore, queried through a 7-method DataAdapter you can implement in an afternoon.
  • DB-backed, not git-backed — unlike git-based CMSes, content is rows, so it works for apps, not just static sites.
  • Framework-agnostic core — the engine is getSnapshot()/subscribe() (the same seam Zustand and Redux use). better-content/core imports zero framework code; the React, Vue, and Svelte bindings are thin adapters over the same engine.
  • Inline editing as the primary UX — headless contentEditable primitives with data-cms-* styling hooks. No admin panel required.
  • Transport-agnostic — the engine speaks a 3-method Transport (save/patch/remove). REST ships as the default; swap in tRPC, server actions, or a direct adapter without touching the engine.

Install

Scaffold a working app (Next.js, Nuxt, SvelteKit, or Astro; Postgres or Firestore):

npm create better-content@latest

Or add it to a project you already have:

npm install better-content

React, database drivers, and SDKs are optional peer dependencies — install only what you use (react, drizzle-orm + pg, firebase-admin, firebase, cloudinary).

Quickstart (React + REST)

1. Wrap your page and make a field editable:

import { restTransport } from "better-content/core";
import {
  AnonymousEditProvider,
  ContentEditSpan,
  PageProvider,
} from "better-content/react";

export default function Page({ initialItems }) {
  return (
    <AnonymousEditProvider>
      <PageProvider transport={restTransport()} initialItems={initialItems}>
        <ContentEditSpan as="h1" collection="sections" itemId="hero" fieldKey="heading" />
      </PageProvider>
    </AnonymousEditProvider>
  );
}

2. Mount the API (Next.js App Router shown; any Request/Response runtime works):

// app/api/admin/[collection]/[id]/route.ts
import { createCmsHandlers } from "better-content/server";
import { PostgresDataAdapter } from "better-content/adapters/postgres";
import { firebaseAuth } from "better-content/auth/firebase";
import { schema } from "@/db/schema";

export const { GET, PATCH, PUT, DELETE } = createCmsHandlers({
  data: new PostgresDataAdapter({ connectionString: process.env.DATABASE_URL, schema }),
  auth: firebaseAuth({ adminEmails: ["you@example.com"] }),
});

3. Load initial content on the server:

import { loadItemMap } from "better-content/server";

const initialItems = await loadItemMap(data, {
  sections: { defaults: [{ id: "hero", heading: "Hello" }], merge: "byId" },
});

Toggle edit mode, click the text, type, save. Field edits are deferred (buffered locally, flushed by saveAll); item operations (create/update/delete/reorder) are immediate and optimistic with rollback.

Fine-grained re-renders: useCmsItem(collection, id) subscribes a component to a single item, so editing one field re-renders one editor, not the page. useCmsEngine() returns the stable engine for calling operations without subscribing.

See your data while you build: mount the dev inspector and get a floating button that opens a live view of your collections, refreshed on every save.

import { DataInspector } from "better-content/devtools/react";

{process.env.NODE_ENV === "development" && (
  <DataInspector adapter={adapter} engine={engine} collections={["sections"]} />
)}

It is a framework-free custom element underneath (better-content/devtools), so Vue, Svelte, and plain HTML apps can use registerDataInspector() + <better-content-inspector> directly.

Subpath exports

Import What Runs on
better-content the same as /core; the bare specifier is an alias anywhere
better-content/core engine, Transport (restTransport, adapterTransport, inMemoryTransport), types anywhere
better-content/react PageProvider, ContentEditSpan, EditableImage, useCmsItem, useMarkdownEditor, auth context client
better-content/svelte engineStore, itemStore, contentEdit action, imageEdit, markdownEdit client
better-content/vue useCmsSnapshot, useCmsItem, vContentEdit directive, useEditableImage, useMarkdownEditor client
better-content/devtools <better-content-inspector> custom element: live database view for development client
better-content/devtools/react typed DataInspector React wrapper for the element client
better-content/server createCmsHandlers, createContentHandler, createAdminGate, loadItemMap, seedItemMap, resolveRelations server
better-content/adapters/postgres Drizzle-backed, typed-only adapter server
better-content/adapters/firestore Firestore adapter (throws on unsupported ops) server
better-content/storage/cloudinary client upload half (pure fetch) client
better-content/storage/cloudinary/server signature endpoint half server
better-content/auth/firebase request verification (AuthAdapter) server
better-content/auth/firebase/client FirebaseAuthProvider + useFirebaseAuth client

Client subpaths never import server SDKs — safe to bundle.

Adapters

Postgres is typed-only: every collection is a Drizzle pgTable you declare and migrate yourself (Drizzle Kit). Writes to undeclared fields or unregistered collections throw. No JSONB fallback, no hidden DDL.

Firestore maps the neutral query ops it can honor natively and throws on the rest (contains, OR groups) rather than silently misleading.

Bring your own backend by implementing DataAdapter — 7 methods (fetchById, fetchCollection, create, createWithId, update, upsert, delete) against a neutral Query. The writes are specified so behavior does not change with the backend: createWithId rejects an id that already exists, upsert writes either way and merges, and delete succeeds on a missing id. Replace a document with delete then createWithId.

Auth

Server: an AuthAdapter resolves a request to an identity; createAdminGate rejects non-admins (401 with { logout: true }, or 403). Firebase ships built-in: admin custom claim and an email allowlist must agree.

Client: AnonymousEditProvider lets visitors toggle edit mode locally on a live site while every write stays gated server-side — try-before-you-buy editing with zero risk.

Development

npm test          # vitest (in-memory transport + PGlite — no infra needed)
npm run build     # tsup: ESM + CJS + .d.ts per subpath

The examples/playground Astro app is the live demo: React, Vue, and Svelte islands editing one shared engine, persisted to Postgres running in the browser (PGlite), with swappable adapters. npm run dev there runs it with zero external services.

License & positioning

MIT. Independent project — not affiliated with better-auth or the better-* family; it shares their values: own your data, framework-agnostic, TypeScript-first, MIT.

About

Own-your-data, adapter-driven, framework-agnostic inline-edit CMS engine. MIT.

Topics

Resources

Stars

16 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages