opacacms
v0.3.21
Published
OpacaCMS: A lightweight, type-safe, and developer-first Headless CMS for the edge and beyond.
Maintainers
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/
├── opaca.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 opaca.config.ts is the single source of truth. Export its configuration as the default export.
import { defineConfig } from 'opacacms';
import { createSQLiteAdapter } from 'opacacms/db';
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 { defineCollection, z } from 'opacacms';
export const posts = defineCollection({
slug: 'posts',
label: 'Blog Posts',
admin: {
icon: 'FileText',
useAsTitle: 'title',
},
schema: z.object({
title: z.text({ label: 'Post Title' }).required(),
slug: z.slug({ from: 'title' }).unique(),
content: z.richText({ label: 'Content' }).localized(),
author: z.relationship('_users', { label: 'Author' }),
status: z
.select(['draft', 'published'], { label: 'Status' })
.default('draft'),
featured: z.boolean({ 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 Configuration
| Property | Description |
| ------------ | --------------------------------------------------------- |
| slug | The unique identifier for the collection |
| label | Display name used in the Admin UI |
| admin | UI configuration (icon, useAsTitle, defaultColumns) |
| schema | A z.object defining the data structure |
| access | Collection-level access control |
| hooks | Lifecycle hooks (beforeCreate, afterUpdate, etc.) |
| webhooks | External webhook notifications |
| versions | Enable document versioning with history |
| timestamps | Customize or disable timestamp fields |
🧪 Field Types
| Field | Usage | Description |
| ------------------ | ------------------------------------------------------- | --------------------------------------------- |
| z.text() | z.text({ label: 'Title' }) | Simple string input |
| z.textarea() | z.textarea({ label: 'Bio' }) | Multi-line text area |
| z.number() | z.number({ label: 'Price' }) | Numeric input |
| z.richText() | z.richText({ label: 'Content' }) | Block-based Lexical editor (Notion style!) 📝 |
| z.relationship() | z.relationship('_users', { label: 'Author' }) | Links to another collection |
| z.file() | z.file({ label: 'Image' }) | File/image upload ☁️ |
| z.blocks() | z.blocks([hero, gallery], { label: 'Layout' }) | Dynamic page builder 🧱 |
| z.group() | z.group({ meta1: z.text(), meta2: z.text() }) | Nested object group |
| z.array() | z.array(z.object({ tag: z.text() })) | Repeatable field array |
| z.select() | z.select(['draft', 'published'], { label: 'Status' }) | Dropdown picker |
| z.boolean() | z.boolean({ label: 'Active' }) | Boolean toggle |
| z.slug() | z.slug({ from: 'title', label: 'Slug' }) | Auto-generated URL slug |
| z.date() | z.date({ label: 'Published At' }) | Date/time picker |
| z.tabs() | z.tabs({ SEO: z.object({...}) }) | UI-only grouping for the admin |
| z.join() | z.join('posts', { foreignKey: 'author' }) | Virtual reversed relationship (read-only) |
| z.password() | z.password({ label: 'Password' }) | Secure, hashed input |
Common Field Methods
Every field type inherits these chainable methods from the base builder:
z.text({ label: 'Email Address', description: 'Primary email' })
.required() // Native Zod or custom required condition
.unique() // Unique constraint in the DB
.localized() // Enable per-locale values (i18n)
.default('[email protected]') // Native Zod default value
.email('Invalid email address') // Native Zod validation
.cms({
admin: {
readOnly: true,
hidden: false,
components: { Field: 'my-custom-field' },
},
}); // Advanced CMS and Admin UI options✅ Validation
OpacaCMS natively supports validation since the entire schema is built using Zod. You can chain standard Zod validation methods directly to your fields.
Zod Schema Validation
Errors are automatically mapped and handled by the API and Admin UI:
import { z } from 'opacacms';
// Regular expressions
z.text().regex(/^\d{3}\.\d{3}\.\d{3}-\d{2}$/, 'Invalid CPF format');
// String constraints
z.text()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain at least one uppercase letter');
// Built-in format validators
z.text().required().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 { defineGlobal, z } from 'opacacms';
export const siteSettings = defineGlobal({
slug: 'site-settings',
label: 'Site Settings',
admin: {
icon: 'Settings',
},
schema: z.object({
siteName: z.text().required().default('My Shiny App'),
tagline: z.text().localized(),
logo: z.file(),
social: z.group({
twitter: z.text(),
github: z.text(),
}),
}),
});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
defineCollection({
// ...
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 using Zod conditional methods and CMS metadata:
z.text({ label: 'Internal Notes' })
.condition((data) => data.role === 'admin') // Only show if another field matches
.cms({
admin: {
hidden: true, // Hide from Admin UI
readOnly: 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.
defineCollection({
// ...
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).
defineCollection({
slug: 'orders',
webhooks: [
{
type: 'outgoing',
url: 'https://hooks.slack.com/services/xxx',
events: ['after:create', 'after:update'],
headers: { Authorization: 'Bearer my-token' },
},
{
type: 'outgoing',
url: 'https://api.example.com/webhooks/orders',
events: ['after:delete'],
},
],
});Supported events: after:create, after:update, after:delete. OpacaCMS also supports incoming webhooks.
📌 Versioning
Enable document versioning to keep a full history of changes. Each save creates a snapshot that can be restored later.
defineCollection({
slug: 'posts',
versions: { drafts: true, maxRevisions: 10 },
schema: z.object({...})
});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. Use z.helpers() to infer the collection types for type-safe resolving.
const userShape = z.object({
firstName: z.text(),
lastName: z.text(),
});
const { meta } = z.helpers(userShape);
export const users = defineCollection({
slug: 'users',
schema: z.object({
...userShape.shape,
fullName: meta(z.text(), {
isVirtual: true,
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.
| Adapter | Import | Usage |
| ------------- | ------------- | --------------------------------- |
| SQLite (Bun) | opacacms/db | createSQLiteAdapter('local.db') |
| Cloudflare D1 | opacacms/db | createD1Adapter(env.DB) |
| PostgreSQL | opacacms/db | createPostgresAdapter(url) |
🔄 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
z.text({ label: 'Title' }).localized()
z.richText({ label: '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
z.text({ label: 'Color' }).cms({
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 = defineCollection({
slug: 'internal_data',
admin: {
hidden: true, // Only accessible via direct link
},
schema: z.object({...}),
});🔌 The Client SDK
Query your CMS like a pro with full type-safety. ⚡️
import { createClient } from 'opacacms';
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';
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')
.then((m) => {
// Access admin webcomponent if needed
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';
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;// opaca.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.header('Content-Type', 'text/html'), c.body(\`
<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 opaca.config.ts:
export default defineConfig({
// ...
plugins: [myPlugin()],
});