npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

Iโ€™ve always been into building performant and accessible sites, but lately Iโ€™ve been taking it extremely seriously. So much so that Iโ€™ve been building a tool to help me optimize and monitor the sites that I build to make sure that Iโ€™m making an attempt to offer the best experience to those who visit them. If youโ€™re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, ๐Ÿ‘‹, Iโ€™m Ryan Hefnerย  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If youโ€™re interested in other things Iโ€™m working on, follow me on Twitter or check out the open source projects Iโ€™ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soonโ€“ish.

Open Software & Tools

This site wouldnโ€™t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you ๐Ÿ™

ยฉ 2026 โ€“ย Pkg Stats / Ryan Hefner

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

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 dev

Adding 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 migrate

When 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=all

When 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:3000

Runtime 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()],
});