@harnonlabs/cms
v0.6.0
Published
Headless CMS library for React with Cloudflare D1, inline editing, and admin components
Downloads
579
Maintainers
Readme
@harnonlabs/cms
A headless CMS library for React applications backed by Cloudflare D1 databases. Provides server-side CRUD API handlers and client-side inline editing components with preview/production mode support.
Features
- Server-side API handler — Drop-in Next.js App Router route handlers for schema sync, CRUD, pagination, and publish
- Inline editing — contentEditable text editing and image picking with hover overlays
- Admin components — Entity list, record table, and record form components for building admin panels
- Preview/production modes — Edit in preview, publish to production with a single API call
- D1 integration — Built for Cloudflare D1 with parameterized queries and automatic schema management
- Dual ESM/CJS — Ships both ESM and CJS bundles with full TypeScript declarations
Installation
npm install @harnonlabs/cmsPeer dependencies
npm install react react-domSupports React 18 and 19.
Database Setup
Create two D1 databases — one for preview and one for production:
npx wrangler d1 create cms-preview
npx wrangler d1 create cms-productionAdd the bindings to your wrangler.toml:
[[d1_databases]]
binding = "CMS_PREVIEW_DB"
database_name = "cms-preview"
database_id = "<your-preview-database-id>"
[[d1_databases]]
binding = "CMS_PRODUCTION_DB"
database_name = "cms-production"
database_id = "<your-production-database-id>"API Route Setup
Create a catch-all API route in your Next.js App Router project:
// app/api/cms/[...path]/route.ts
import { createCMSHandler } from "@harnonlabs/cms";
import type { CMSConfig } from "@harnonlabs/cms";
const config: CMSConfig = {
getDB: (mode) => {
// Return the appropriate D1 binding based on mode
const env = process.env as unknown as {
CMS_PREVIEW_DB: D1Database;
CMS_PRODUCTION_DB: D1Database;
};
return mode === "production" ? env.CMS_PRODUCTION_DB : env.CMS_PREVIEW_DB;
},
};
const handler = createCMSHandler(config);
export const GET = handler.GET;
export const POST = handler.POST;
export const PUT = handler.PUT;
export const DELETE = handler.DELETE;API Routes
| Method | Path | Description |
|--------|------|-------------|
| POST | /api/cms/sync | Sync entity schemas to both databases |
| GET | /api/cms/entities | List all CMS tables |
| GET | /api/cms/entities/:name | Get entity schema columns |
| GET | /api/cms/entities/:name/records | List records (supports ?limit= and ?offset=) |
| POST | /api/cms/entities/:name/records | Create a record |
| PUT | /api/cms/entities/:name/records/:id | Update a record |
| DELETE | /api/cms/entities/:name/records/:id | Delete a record |
| POST | /api/cms/entities/:name/publish | Copy preview data to production |
The database mode is read from the X-CMS-Mode header, then the ?mode= query parameter, defaulting to "preview".
Layout Integration
Wrap your app with CMSProvider to enable inline editing:
// app/layout.tsx
import { CMSProvider } from "@harnonlabs/cms/client";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<CMSProvider enabled={true} apiPath="/api/cms">
{children}
</CMSProvider>
</body>
</html>
);
}CMSProvider Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| children | ReactNode | — | Child components |
| apiPath | string | "/api/cms" | Base path for CMS API requests |
| enabled | boolean | true | Enable/disable CMS overlays |
useCMS Hook
Access CMS state and actions from any child component:
import { useCMS } from "@harnonlabs/cms/client";
function MyComponent() {
const {
entities, // Map<string, EntityMeta>
mode, // "preview" | "production"
enabled, // boolean
setMode, // (mode: CMSMode) => void
syncSchema, // (entities: EntityMeta[]) => Promise<void>
addRecord, // (entityName, data?) => Promise<Record>
updateField, // (entityName, recordId, field, value) => Promise<Record>
deleteRecord,// (entityName, recordId) => Promise<void>
publish, // (entityName) => Promise<{ count: number }>
} = useCMS();
}Page CMS Attributes
Annotate your page elements with data attributes to make them CMS-editable:
// app/page.tsx
export default function HomePage() {
return (
<section data-cms-en="blog_post">
<article data-cms-id="1">
<h2 data-cms-prop="title">My First Post</h2>
<p data-cms-prop="body">Post content goes here...</p>
<img data-cms-prop="hero_image" src="/placeholder.jpg" alt="Hero" />
</article>
</section>
);
}Attribute Reference
| Attribute | Description |
|-----------|-------------|
| data-cms-en | Marks an element as a CMS entity container. Value is the entity name (e.g., "blog_post"). |
| data-cms-id | Identifies a specific record within an entity. Value is the record ID. |
| data-cms-prop | Marks an element as an editable CMS field. Value is the field/column name. |
Entity names are automatically converted to table names with a cms_ prefix (e.g., blog_post becomes cms_blog_post). Field types are inferred from the DOM element:
<img>or elements withbackground-imagestyle are stored asTEXTwith the image flag<input type="number">or<input type="range">are stored asREAL- Text content that parses as a number is stored as
REAL - Everything else is stored as
TEXT
Admin Panel Setup
Build an admin page using the provided components:
// app/admin/page.tsx
"use client";
import { useState } from "react";
import { AdminEntityList } from "@harnonlabs/cms/client";
import { AdminRecordTable } from "@harnonlabs/cms/client";
import { AdminRecordForm } from "@harnonlabs/cms/client";
export default function AdminPage() {
const [selectedEntity, setSelectedEntity] = useState<string | null>(null);
const [mode, setMode] = useState<"preview" | "production">("preview");
return (
<div style={{ display: "flex" }}>
<aside style={{ width: 240 }}>
<AdminEntityList
selectedEntity={selectedEntity}
onSelectEntity={setSelectedEntity}
mode={mode}
/>
</aside>
<main style={{ flex: 1 }}>
<label>
<input
type="checkbox"
checked={mode === "production"}
onChange={(e) =>
setMode(e.target.checked ? "production" : "preview")
}
/>
Production mode
</label>
{selectedEntity && (
<AdminRecordTable entityName={selectedEntity} mode={mode} />
)}
</main>
</div>
);
}Admin Component Props
AdminEntityList — Sidebar listing discovered CMS entities.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| selectedEntity | string \| null | — | Currently selected entity name |
| onSelectEntity | (name: string) => void | — | Called when an entity is clicked |
| apiPath | string | "/api/cms" | API base path |
| mode | string | "preview" | Database mode |
AdminRecordTable — Table displaying records for a selected entity.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| entityName | string | — | Entity to display records for |
| onEditRecord | (record) => void | — | Called when edit button is clicked |
| apiPath | string | "/api/cms" | API base path |
| mode | string | "preview" | Database mode |
AdminRecordForm — Modal form for creating and editing records.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| entityName | string | — | Entity name |
| columns | ColumnInfo[] | — | Schema columns for the entity |
| record | Record \| null | null | Record to edit, or null for create mode |
| onClose | () => void | — | Called when modal is closed |
| onSaved | (record) => void | — | Called after successful save |
| apiPath | string | "/api/cms" | API base path |
| mode | string | "preview" | Database mode |
CLI
The package includes a CLI tool (rlooper-cms) to scaffold CMS files into a Next.js project.
Quick Start
npx rlooper-cms initOr install globally:
npm install -g @harnonlabs/cms
rlooper-cms initinit Command
The init command runs an interactive wizard that detects your project setup and generates boilerplate files:
- Detects Next.js, TypeScript, and the app directory (
src/apporapp) - Prompts you to configure:
- API route base path (default:
/api/cms) - Whether to generate an API route handler
- Whether to wrap your root layout with
CMSProvider - Whether to generate an admin panel page
- Whether to set up Cloudflare D1 databases
- API route base path (default:
- Generates the selected files, creating directories as needed
Generated Files
| File | Description |
|------|-------------|
| app/api/cms/[...path]/route.ts | Catch-all API route handler with D1 integration |
| app/layout.tsx | Root layout wrapped with CMSProvider |
| app/admin/page.tsx | Admin panel with entity list and record table |
| wrangler.toml | Cloudflare D1 database bindings (if D1 setup selected) |
File extensions are .ts/.tsx or .js/.jsx depending on whether TypeScript is detected. If a file already exists, you are prompted before overwriting.
Build and Deploy
# Build the library
npm run build
# Deploy your consuming application
npx wrangler deployTypes
All types are exported from the server entry point:
import type {
CMSMode, // "preview" | "production"
FieldMeta, // { name, columnName, columnType, isImage }
EntityMeta, // { name, tableName, fields: FieldMeta[] }
CMSConfig, // { getDB: (mode: CMSMode) => D1Database }
CMSHandlerResult, // { GET, POST, PUT, DELETE }
SyncPayload, // { entities: EntityMeta[] }
} from "@harnonlabs/cms";License
MIT
