opacacms
v0.2.1
Published
> **The Headless CMS that doesn't get in your way.** Define your schema, access control, and logic directly in TypeScript. No visual builders, no proprietary formats, no lock-in. Just code. ๐ป
Readme
๐ OpacaCMS
The Headless CMS that doesn't get in your way. Define your schema, access control, and logic directly in TypeScript. No visual builders, no proprietary formats, no lock-in. Just code. ๐ป
OpacaCMS is a runtime-agnostic powerhouse that runs anywhere: Node.js, Bun, Cloudflare Workers, and Next.js. Powered by Hono, it turns your schema into database tables and a high-performance REST API instantly. โก๏ธ
๐งญ Quick Menu
- โจ Getting Started
- ๐ Project Structure
- โ๏ธ Configuration
- ๐ฆ Collections
- ๐งช Field Types
- โ Validation
- ๐ Globals
- ๐ Access Control
- โ Hooks
- ๐ Webhooks
- ๐ Versioning
- ๐งฎ Virtual Fields
- ๐ค Authentication
- ๐ Logging
- ๐ Database Adapters
- ๐ Migrations
- โ๏ธ Storage
- ๐ Internationalization (i18n)
- ๐จ Custom Admin Components
- ๐ API & SDK
- ๐ Full-Stack Examples
โจ Getting Started
You'll need Bun (highly recommended) or Node.js 18+.
# Kickstart a new project
bunx opacacms init my-awesome-cms
cd my-awesome-cms
bun install
bun devAdding to an existing project? Easy: bun add opacacms ๐ฆ
๐ Project Structure
my-cms/
โโโ opacacms.config.ts โ The heart of your CMS (schema + DB + auth)
โโโ migrations/ โ Your DB history
โโโ collections/ โ Where your data models live
โ โโโ posts.ts
โ โโโ products.ts
โ โโโ ...
โโโ globals/ โ Singleton documents (settings, etc.)
โ โโโ site-settings.ts
โ โโโ ...
โโโ src/ โ Your app logicโ๏ธ Configuration
Your opacacms.config.ts is the single source of truth. Export its configuration as the default export.
import { defineConfig } from 'opacacms/config';
import { createSQLiteAdapter } from 'opacacms/db/sqlite';
import { posts } from './collections/posts';
import { siteSettings } from './globals/site-settings';
export default defineConfig({
appName: 'My Shiny Blog ๐ซ',
serverURL: 'http://localhost:3000',
secret: process.env.OPACA_SECRET,
db: createSQLiteAdapter('local.db'),
collections: [posts],
globals: [siteSettings],
i18n: {
locales: ['en', 'pt-BR', 'tr'],
defaultLocale: 'en',
},
auth: {
strategies: { emailPassword: true },
features: {
apiKeys: { enabled: true },
},
},
logger: { level: 'debug' },
});Configuration Options
| Option | Type | Description |
| ---------------- | -------------------------------- | ----------------------------------------------------------------- |
| appName | string | Display name shown in the Admin UI sidebar |
| serverURL | string | The base URL of your server (used for CORS, auth callbacks, etc.) |
| secret | string | Secret used for signing tokens and encryption |
| db | DatabaseAdapter | Database adapter (createSQLiteAdapter, createD1Adapter, etc.) |
| collections | Collection[] | Array of collection definitions |
| globals | Global[] | Array of global definitions |
| i18n | { locales, defaultLocale } | Internationalization config |
| auth | AuthConfig | Authentication strategies and features |
| logger | { level, disabled? } | Logger configuration |
| trustedOrigins | string[] | Origins allowed for CORS requests |
| storages | Record<string, StorageAdapter> | Named storage adapters for file uploads |
| api | { maxLimit? } | API-level settings (e.g., max items per page) |
๐ฆ Collections
A Collection is a database table + a REST API. Pure magic. โจ
// collections/posts.ts
import { Collection, Field } from 'opacacms/schema';
export const posts = Collection.create('posts')
.label('Blog Posts')
.icon('FileText')
.fields([
Field.text('title').required().label('Post Title'),
Field.slug('slug').from('title').unique(),
Field.richText('content').localized(),
Field.relationship('author').to('_users').single(),
Field.select('status')
.options([
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
])
.defaultValue('draft'),
Field.checkbox('featured').label('Featured Post'),
])
.access({
read: () => true,
create: ({ user }) => !!user,
update: ({ user }) => user?.role === 'admin',
delete: ({ user }) => user?.role === 'admin',
})
.hooks({
beforeCreate: async (data) => {
// Mutate data before insertion
return { ...data, publishedAt: new Date().toISOString() };
},
afterCreate: async (doc) => {
console.log('New post created:', doc.id);
},
});Collection Builder Methods
| Method | Description |
| -------------------- | ---------------------------------------------------------------- |
| .label(name) | Sets the display name used in the Admin UI sidebar |
| .icon(name) | Lucide icon name for the sidebar |
| .fields([...]) | Defines the data structure for this collection |
| .access(rules) | Collection-level access control |
| .hooks(fns) | Lifecycle hooks (beforeCreate, afterUpdate, etc.) |
| .webhooks([...]) | External webhook notifications |
| .admin({...}) | Advanced Admin UI configuration (hidden, disableAdmin, etc.) |
| .versions(true) | Enable document versioning with history |
| .timestamps({...}) | Customize timestamp field names |
๐งช Field Types
We've got everything you need to build powerful schemas:
| Field | Usage | Description |
| ---------------------- | ------------------------------------------- | --------------------------------------------- |
| Field.text() | Field.text('title') | Simple string input |
| Field.number() | Field.number('price') | Numeric input |
| Field.richText() | Field.richText('content') | Block-based Lexical editor (Notion style!) ๐ |
| Field.relationship() | Field.relationship('author').to('_users') | Links to another collection |
| Field.file() | Field.file('image') | File/image upload โ๏ธ |
| Field.blocks() | Field.blocks('layout').blocks([...]) | Dynamic page builder ๐งฑ |
| Field.group() | Field.group('meta').fields([...]) | Nested object group |
| Field.array() | Field.array('tags').fields([...]) | Repeatable field group |
| Field.select() | Field.select('status').options([...]) | Dropdown picker |
| Field.checkbox() | Field.checkbox('active') | Boolean toggle |
| Field.slug() | Field.slug('slug').from('title') | Auto-generated URL slug |
| Field.date() | Field.date('publishedAt') | Date/time picker |
| Field.virtual() | Field.virtual('fullName').resolve(...) | Computed field (not stored) |
| Field.tabs() | Field.tabs('layout').tabs([...]) | UI-only grouping for the admin |
Common Field Methods
Every field type inherits these chainable methods from the base builder:
Field.text('email')
.label('Email Address') // Admin UI label
.placeholder('[email protected]') // Input placeholder
.required() // Mark as required
.unique() // Unique constraint in the DB
.localized() // Enable per-locale values (i18n)
.defaultValue('[email protected]') // Default value
.validate(z.string().email()) // Custom validation (function or Zod)
.access({ readOnly: true }) // Field-level access control
.description('Primary email') // Help text below the field
.hidden() // Hide from Admin UI
.readOnly() // Read-only in Admin UI
.admin({ components: { Field: 'my-custom-field' } }); // Custom componentโ Validation
OpacaCMS supports granular field validation via the .validate() method. You can pass either a custom function or a Zod schema โ they're fully interchangeable.
Custom Function Validation
Return true to pass, or a string error message to fail:
Field.text('username').validate((value) => {
if (value === 'admin') return "Username 'admin' is reserved";
return true;
});Zod Schema Validation
Pass any z.ZodTypeAny schema directly. Errors are automatically mapped:
import { z } from 'zod';
Field.text('cpf').validate(
z.string().regex(/^\d{3}\.\d{3}\.\d{3}-\d{2}$/, 'Invalid CPF format'),
);
Field.text('password').validate(
z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain at least one uppercase letter'),
);
Field.text('email')
.required()
.validate(z.string().email('Invalid email address'));๐ก Tip: Zod validation integrates seamlessly with
.required(). The built-in requirement check runs first, then your Zod schema validates the value shape.
๐ Globals
Globals are singleton documents โ perfect for site settings, navigation, footers, and other one-of-a-kind configs.
import { Global, Field } from 'opacacms/schema';
export const siteSettings = Global.create('site-settings')
.label('Site Settings')
.icon('Settings')
.fields([
Field.text('siteName').required(),
Field.text('tagline').localized(),
Field.file('logo'),
Field.group('social').fields([Field.text('twitter'), Field.text('github')]),
]);API: GET /api/globals/site-settings and PUT /api/globals/site-settings
๐ Access Control
Secure your data with simple functions at both collection and field levels. ๐ก๏ธ
Collection-Level Access
.access({
read: ({ user }) => !!user, // Logged in? You're good.
create: ({ user }) => user?.role === 'admin', // Only admins please!
update: ({ data, user }) => data.ownerId === user.id, // Only your own stuff.
delete: ({ user }) => user?.role === 'admin',
requireApiKey: true, // Require API key for programmatic access
})Field-Level Access
Control visibility and editability per-field:
Field.text('internalNotes').access({
hidden: ({ user }) => user?.role !== 'admin', // Only admins see this
readOnly: ({ operation }) => operation === 'update', // Editable only on create
disabled: false,
});Role-Based Access Control (RBAC)
Combine auth with the access property to define granular permissions:
access: {
roles: {
admin: {
posts: ['read', 'create', 'update', 'delete'],
users: ['read', 'update']
},
editor: {
posts: ['read', 'update']
}
}
}โ Hooks
Hooks let you run side-effects at key points in the document lifecycle. They receive the document data and can mutate it before persistence.
.hooks({
beforeCreate: async (data) => {
// Transform or enrich data before saving
data.slug = data.title.toLowerCase().replace(/\s+/g, '-');
return data;
},
afterCreate: async (doc) => {
// Side-effects after the document is saved
await sendWelcomeEmail(doc.email);
},
beforeUpdate: async (data) => {
data.updatedBy = 'system';
return data;
},
afterUpdate: async (doc) => {
await invalidateCache(`/posts/${doc.slug}`);
},
beforeDelete: async (id) => {
await archiveDocument(id);
},
afterDelete: async (id) => {
console.log(`Document ${id} deleted`);
},
})๐ Webhooks
Send HTTP notifications to external services when documents change. Webhooks run in the background using waitUntil on supported runtimes (Cloudflare Workers, Vercel Edge).
Collection.create('orders').webhooks([
{
url: 'https://hooks.slack.com/services/xxx',
events: ['afterCreate', 'afterUpdate'],
headers: { Authorization: 'Bearer my-token' },
},
{
url: 'https://api.example.com/webhooks/orders',
events: ['afterDelete'],
},
]);Supported events: afterCreate, afterUpdate, afterDelete.
๐ Versioning
Enable document versioning to keep a full history of changes. Each save creates a snapshot that can be restored later.
Collection.create('posts')
.versions(true) // That's it!
.fields([...])Version API
| Endpoint | Method | Description |
| ---------------------------------------- | ------ | -------------------------------- |
| /api/posts/versions?parentId=xxx | GET | List all versions for a document |
| /api/posts/versions/:versionId/restore | POST | Restore a specific version |
The admin UI provides a visual "Versions" panel where editors can browse and restore past versions.
๐งฎ Virtual Fields
Virtual fields are computed at read-time and never stored in the database. Perfect for derived values, aggregated data, or API mashups.
Field.virtual('fullName').resolve(async ({ data, user, req }) => {
return `${data.firstName} ${data.lastName}`;
});Virtual fields receive the full document data, the current user, session, apiKey, and the Hono req context.
๐ค Authentication
OpacaCMS features a robust, built-in authentication system powered by Better Auth. It's secure by default and fully customizable.
Basic Setup
auth: {
strategies: {
emailPassword: true, // Enabled by default
magicLink: {
enabled: true,
sendEmail: async ({ email, url }) => {
await sendMyMagicLink(email, url);
}
}
},
features: {
apiKeys: { enabled: true }, // Programmable access
mfa: { enabled: true, issuer: 'My App' } // Two-Factor Auth
}
}API Key Authentication
When apiKeys is enabled, you can create API keys with fine-grained collection permissions:
// API keys can have per-collection permissions
{
permissions: {
posts: ['read', 'create'],
users: ['read']
}
}Pass the key in your requests:
curl -H "Authorization: Bearer opaca_key_xxx" https://api.mycms.com/api/posts๐ Logging
OpacaCMS includes a configurable global logger that standardizes output across the core system and authentication events.
logger: {
level: 'debug', // 'debug' | 'info' | 'warn' | 'error'
disabled: false,
disableColors: false
}Access the logger in custom middleware:
const logger = c.get('logger');
logger.info('Custom route hit');๐ Database Adapters
OpacaCMS provides first-class adapters for multiple database engines. All adapters implement the same interface, so switching is as simple as changing one line.
| Adapter | Import | Usage |
| ------------- | -------------------- | --------------------------------- |
| SQLite (Bun) | opacacms/db/sqlite | createSQLiteAdapter('local.db') |
| Cloudflare D1 | opacacms/db/d1 | createD1Adapter(env.DB) |
๐ Migrations
Migrations keep your database schema in sync with your collections. They're auto-generated from your field definitions.
# Create a migration
bunx opacacms migrate:create initial-schema
# Apply migrations
bunx opacacms migrateWhen using createBunHandler or createCloudflareWorkersHandler, migrations run automatically on startup via db.migrate(config.collections).
โ๏ธ Storage
OpacaCMS supports pluggable storage adapters for file uploads. You can define multiple named storages and reference them per-field.
import { createR2Storage } from 'opacacms/storage';
storages: {
default: createR2Storage({
bucketBinding: env.BUCKET,
publicUrl: 'https://cdn.example.com',
}),
secure: createR2Storage({
bucketBinding: env.SECURE_BUCKET,
publicUrl: 'https://secure.example.com',
}),
}๐ Internationalization (i18n)
Enable field-level localization with a simple config and the .localized() method on any field.
// Config
i18n: {
locales: ['en', 'pt-BR', 'tr'],
defaultLocale: 'en',
}
// Field
Field.text('title').localized()
Field.richText('content').localized()Locale Selection
Pass the desired locale in your API requests:
# Via header
curl -H "x-opaca-locale: pt-BR" https://api.mycms.com/api/posts
# Via query parameter
curl https://api.mycms.com/api/posts?locale=pt-BR
# Get all locales
curl https://api.mycms.com/api/posts?locale=allWhen writing data, send the locale header and the value will be stored under that locale key automatically. The system handles merging โ existing locale values are preserved.
๐จ Custom Admin Components
This is where OpacaCMS shines. You can replace any field UI with your own React or Vue components via Web Components. ๐
1๏ธโฃ React Components
// MyColorPicker.tsx
import { defineReactField } from 'opacacms/admin';
const ColorPicker = ({ value, onChange }) => (
<input
type="color"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
defineReactField('my-color-picker', ColorPicker);2๏ธโฃ Vue Components
// MyVuePicker.vue
import { defineVueField } from 'opacacms/admin';
import { createApp } from 'vue';
import MyVueComponent from './MyVueComponent.vue';
defineVueField('my-vue-picker', MyVueComponent, { createApp });3๏ธโฃ Reference in Schema
Field.text('color').admin({
components: {
Field: 'my-color-picker',
},
});๐ Advanced Admin Configuration
Collections and Fields can be further customized for the Admin UI using the .admin() method.
Collection Admin Options
| Option | Type | Description |
| ---------------- | ---------- | ------------------------------------------------------------------------------- |
| hidden | boolean | If true, hides the collection from the sidebar but keeps it accessible via URL. |
| disableAdmin | boolean | If true, completely removes the collection from the Admin UI. |
| useAsTitle | string | The field name to use as the title in breadcrumbs and lists. |
| defaultColumns | string[] | The default fields to show in the collection list table. |
Example:
export const InternalData = Collection.create('internal_data')
.admin({
hidden: true, // Only accessible via direct link
})
.fields([...]);๐ The Client SDK
Query your CMS like a pro with full type-safety. โก๏ธ
import { createClient } from 'opacacms/client';
const cms = createClient({ baseURL: 'https://api.mycms.com' });
const posts = await cms.collections.posts.find({
limit: 10,
sort: 'createdAt:desc',
// Deep Populate! ๐
populate: {
author: true,
comments: {
populate: {
user: true,
},
},
},
});Filtering & Querying
# Basic filter
GET /api/posts?status=published
# Operator-based filtering
GET /api/posts?price[gt]=10&price[lt]=100
# Pagination
GET /api/posts?page=2&limit=20
# Sorting
GET /api/posts?sort=createdAt:desc
# Deep populate via REST
GET /api/posts?populate=author,comments.user๐ Full-Stack Examples
Next.js (App Router)
OpacaCMS integrates with Next.js via the createNextHandler which wraps the internal Hono router using hono/vercel.
1. API Route Handler
// app/api/[[...route]]/route.ts
import { createNextHandler } from 'opacacms/runtimes/next';
import config from '@/opacacms.config';
export const { GET, POST, PUT, DELETE, PATCH, OPTIONS } =
createNextHandler(config);2. Admin UI Page
The admin interface is delivered as a Web Component โ just import it in a client page and point it at your server:
// app/admin/[[...segments]]/page.tsx
'use client';
import { useEffect, useState } from 'react';
import 'opacacms/admin/ui/styles/index.scss'; // Admin styles
// Declare the web component for TypeScript
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'opaca-admin': {
'server-url'?: string;
config?: string;
};
}
}
}
export default function AdminPage() {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
import('opacacms/admin/webcomponent')
.then(() => setLoaded(true))
.catch((err) => console.error('Failed to load Opaca Admin', err));
}, []);
if (!loaded) return <div>Loading Admin Interface...</div>;
return <opaca-admin server-url="http://localhost:3000" />;
}That's it! Your full-stack Next.js app now has a complete CMS admin panel at /admin and a REST API at /api.
Vue
For Vue, import the pre-built admin bundle and use the web component directly:
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import 'opacacms/admin.css'; // Or the bundled CSS path
const loaded = ref(false);
onMounted(async () => {
try {
await import('opacacms/admin/webcomponent');
loaded.value = true;
} catch (err) {
console.error('Failed to load Opaca Admin', err);
}
});
</script>
<template>
<div v-if="!loaded">Loading Admin Interface...</div>
<opaca-admin v-else server-url="http://localhost:3000" />
</template>Your API server can run as a separate Bun or Node.js process using the standalone handler.
Cloudflare Workers
OpacaCMS runs natively on Cloudflare Workers with D1 (database) and R2 (storage):
// src/index.ts
import { createCloudflareWorkersHandler } from 'opacacms/runtimes/cloudflare-workers';
import config from './opacacms.config';
const app = createCloudflareWorkersHandler(config);
// Serve the admin SPA
app.get('/admin*', (c) => {
return c.html(`
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/admin.css">
</head>
<body>
<opaca-admin server-url="${new URL(c.req.url).origin}"></opaca-admin>
<script type="module" src="/webcomponent.js"></script>
</body>
</html>
`);
});
export default app;// opacacms.config.ts
import { defineConfig } from 'opacacms/config';
import { createD1Adapter } from 'opacacms/db/d1';
import { createR2Storage } from 'opacacms/storage';
const getConfig = (env: Env, request: Request) =>
defineConfig({
appName: 'My Edge CMS',
serverURL: new URL(request.url).origin,
secret: env.OPACA_SECRET,
db: createD1Adapter(env.DB),
storages: {
default: createR2Storage({
bucketBinding: env.BUCKET,
publicUrl: new URL(request.url).origin,
}),
},
collections: [posts, products],
i18n: {
locales: ['en', 'pt-BR'],
defaultLocale: 'en',
},
});
export default getConfig;Bun (Standalone Server)
import { createBunHandler } from 'opacacms/runtimes/bun';
import config from './opacacms.config';
const { app, init } = createBunHandler(config, { port: 3000 });
await init(); // Connects DB, runs migrations, starts server
// ๐ Listening on http://localhost:3000Runtime Handlers
| Runtime | Import | Handler |
| ------------------------ | -------------------------------------- | ---------------------------------------- |
| Next.js (App Router) | opacacms/runtimes/next | createNextHandler(config) |
| Bun (Standalone) | opacacms/runtimes/bun | createBunHandler(config, opts) |
| Cloudflare Workers | opacacms/runtimes/cloudflare-workers | createCloudflareWorkersHandler(config) |
| Node.js | opacacms/runtimes/node | createNodeHandler(config) |
๐ Why OpacaCMS?
- Blazing Fast: Built on Hono & Bun. ๐
- Truly Decoupled: Your data is yours. No hidden SaaS lock-in.
- Developer First: Everything is a typed API. ๐ฉโ๐ป
- Deploy Anywhere: Vercel, Cloudflare, Fly.io, or your own VPS.
- Zod Validation: First-class support for Zod schemas on any field.
- Version History: Full document versioning with one-click restore.
- Edge-Ready: Native Cloudflare D1 + R2 support for global deployments.
Ready to build something awesome? Let's go! ๐
๐ Next-Gen Plugins
OpacaCMS features a powerful, hook-based plugin system that allows you to extend the backend (schema, API middleware, routes) and the Admin UI (custom views, isolated dashboards) with full type-safety.
The definePlugin Helper
Use definePlugin for a type-safe experience and rich metadata support.
// plugins/my-plugin.ts
import { definePlugin, html } from 'opacacms';
export const myPlugin = () =>
definePlugin({
name: 'my-plugin',
label: 'Custom Dashboard',
description: 'A powerful extension for your CMS.',
version: '1.0.0',
icon: 'Activity',
// 1. Hook into Global API Requests
onRequest: async (c) => {
if (c.req.path.startsWith('/api/secret')) {
console.log('Intercepted secret request!');
}
},
// 2. Add API Routes & UI Assets
onRouterInit: (app) => {
// Serve the UI Registration Script
app.get('/api/plugins/my-plugin/setup.js', (c) => {
const js = `
// Use the simplified window.opaca helper
window.opaca.ui.registerAdminRoute({
label: "Plugin Dashboard",
icon: "Activity",
path: "/admin/my-plugin",
render: (serverUrl) => \`
<iframe
src="\${serverUrl}/api/plugins/my-plugin/view"
style="width:100%; height:calc(100vh - 100px); border:none;"
></iframe>
\`
});
`;
return (c.header('Content-Type', 'application/javascript'), c.body(js));
});
// Serve the Isolated HTML View with Hono/HTML
app.get('/api/plugins/my-plugin/view', (c) => {
return c.html(html`
<body
style="background: #f6f9fc; padding: 40px; font-family: sans-serif;"
>
<h1>Modern Plugin UI</h1>
<p>Isolated from CMS styles with zero boilerplate.</p>
</body>
`);
});
},
// 3. Register Assets
adminAssets: () => ({
scripts: ['/api/plugins/my-plugin/setup.js'],
}),
});Plugin Lifecycle Hooks
| Hook | Description |
| ---------------- | ---------------------------------------------------------------------------- |
| onInit | Runs during CMS startup. Used to inject collections or modify global config. |
| onRequest | Global middleware called for EVERY API request. Return false to block. |
| onRouterInit | Called when the API router is being built. Mount custom Hono routes here. |
| onInitComplete | Fired once all plugins and core modules are fully initialized. |
| onDestroy | Cleanup hook for graceful shutdown. |
| onExport | Hook for SSG (Static Site Generation) plugins to export custom files. |
Global Admin Registry (window.opaca)
Plugins can interact with the Admin UI via the window.opaca object:
window.opaca.ui.registerAdminRoute(item): Simplest way to add a new page to the sidebar.window.opaca.ui.notify(message, type): Show a toast notification.window.opaca.ui.toggleSidebar(): Programmatically collapse/expand the menu.
Registering the Plugin
Add your plugin to the plugins array in opacacms.config.ts:
export default defineConfig({
// ...
plugins: [myPlugin()],
});