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.3.21

Published

OpacaCMS: A lightweight, type-safe, and developer-first Headless CMS for the edge and beyond.

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/
├── 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 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
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=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

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