This guide covers how to build and register build-time plugins for ZapBB. Build-time plugins are compiled into the application bundle and are not loaded dynamically at runtime.
- Overview
- Plugin Architecture
- Creating a Plugin
- Plugin Manifest
- Slot Components
- Event Handlers
- Settings Schema
- Admin Pages
- Runtime Context API
- Registering Your Plugin
- Best Practices
ZapBB plugins extend the forum's functionality through:
- Slots: Inject custom UI components into predefined locations
- Events: React to forum events (post created, user registered, etc.)
- Settings: Configurable options stored per-plugin
- Admin Pages: Custom administration interfaces
Plugins are TypeScript/React packages that integrate at build time for optimal performance and type safety.
my-zapbb-plugin/
├── package.json
├── tsconfig.json
├── src/
│ ├── index.ts # Package entry, exports the plugin definition
│ ├── slots/
│ │ ├── PostFooter.tsx
│ │ └── ThreadHeader.tsx
│ └── admin/
│ └── SettingsPage.tsx
└── dist/
mkdir my-zapbb-plugin && cd my-zapbb-plugin
bun initbun add react
bun add -d typescript @types/reactCreate tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}The manifest defines your plugin's metadata, capabilities, and configuration. Use definePlugin() to create a type-safe definition.
// src/index.ts
import { definePlugin } from "@zapbb/plugin-types";
export default definePlugin({
// Required fields
id: "my-zapbb-plugin", // Unique identifier (kebab-case)
name: "My ZapBB Plugin", // Display name
version: "1.0.0", // Semantic version
// Optional metadata
description: "Adds custom functionality to ZapBB",
author: "Your Name",
license: "MIT",
minForumVersion: "0.1.0", // Minimum compatible ZapBB version
maxForumVersion: "1.x.x", // Maximum compatible ZapBB version
// Plugin capabilities (all optional)
slots: { /* ... */ },
events: { /* ... */ },
settingsSchema: { /* ... */ },
adminPages: [ /* ... */ ],
});Slots are predefined locations in the UI where plugins can inject content.
| Slot ID | Location | Props Received |
|---|---|---|
post.header |
Top of each post | { post } |
post.footer |
Bottom of each post | { post } |
post.actions |
Post action buttons area | { post } |
thread.header |
Top of thread view | { thread } |
thread.actions |
Thread action buttons | { thread } |
sidebar.top |
Top of sidebar | {} |
sidebar.bottom |
Bottom of sidebar | {} |
category.header |
Category page header | { category } |
admin.dashboard |
Admin dashboard widgets | {} |
Slot components receive props plus a context object with access to the runtime API.
// src/slots/PostFooter.tsx
import type { PluginSlotComponent } from "@zapbb/plugin-types";
interface PostFooterProps {
post: {
id: string;
author_id: string;
content: string;
};
}
const PostFooter: PluginSlotComponent<PostFooterProps> = ({ post, context }) => {
// Access plugin settings
if (!context.settings.showFooter) {
return null;
}
// Access current user
const isAuthor = context.currentUser?.user_id === post.author_id;
return (
<div className="mt-2 text-xs text-slate-500">
{isAuthor && <span>✓ You wrote this</span>}
<span>Plugin footer for post {post.id}</span>
</div>
);
};
export default PostFooter;Use lazy imports for code splitting:
// src/index.ts
export default definePlugin({
id: "my-plugin",
name: "My Plugin",
version: "1.0.0",
slots: {
"post.footer": () => import("./slots/PostFooter"),
"thread.header": () => import("./slots/ThreadHeader"),
},
});Or use direct component references for simpler cases:
import PostFooter from "./slots/PostFooter";
export default definePlugin({
// ...
slots: {
"post.footer": PostFooter,
},
});Plugins can subscribe to forum events and execute custom logic.
| Event | Payload | Description |
|---|---|---|
post.created |
{ post } |
New post created |
post.updated |
{ post } |
Post edited |
post.deleted |
{ postId } |
Post removed |
thread.created |
{ thread } |
New thread started |
thread.locked |
{ thread } |
Thread locked |
user.registered |
{ user } |
New user signed up |
user.login |
{ user } |
User logged in |
// src/index.ts
export default definePlugin({
id: "my-plugin",
name: "My Plugin",
version: "1.0.0",
events: {
"post.created": async (payload, context) => {
const post = payload as { id: string; content: string };
// Check settings before acting
if (!context.settings.enableNotifications) {
return;
}
// Use the API client
// await context.api.someEndpoint(...)
// Emit custom events
await context.emit("my-plugin.post-processed", { postId: post.id });
},
"user.login": async (payload, context) => {
// Track login in plugin storage
const count = (context.storage.get("loginCount") as number) || 0;
context.storage.set("loginCount", count + 1);
},
},
});Define configurable options for your plugin using a JSON Schema-like format.
export default definePlugin({
id: "my-plugin",
name: "My Plugin",
version: "1.0.0",
settingsSchema: {
type: "object",
properties: {
enableFeature: {
type: "boolean",
title: "Enable Feature",
description: "Toggle the main plugin functionality",
default: true,
},
apiKey: {
type: "string",
title: "API Key",
description: "External service API key",
},
maxItems: {
type: "number",
title: "Maximum Items",
description: "Limit for items to display",
default: 10,
minimum: 1,
maximum: 100,
},
},
required: ["apiKey"],
},
});| Type | Renders As | Additional Properties |
|---|---|---|
boolean |
Toggle switch | default |
string |
Text input | default |
number |
Number input | default, minimum, maximum |
Settings are automatically rendered in Admin → Plugins → [Your Plugin].
Add custom pages to the admin panel for advanced configuration or dashboards.
export default definePlugin({
id: "my-plugin",
name: "My Plugin",
version: "1.0.0",
adminPages: [
{
id: "my-plugin-dashboard",
title: "Plugin Dashboard",
path: "/admin/plugins/my-plugin/dashboard",
component: () => import("./admin/Dashboard"),
},
{
id: "my-plugin-settings",
title: "Advanced Settings",
path: "/admin/plugins/my-plugin/settings",
component: () => import("./admin/AdvancedSettings"),
},
],
});// src/admin/Dashboard.tsx
export default function Dashboard() {
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold">Plugin Dashboard</h1>
<p>Custom admin interface for your plugin.</p>
</div>
);
}Every slot component and event handler receives a context object with these properties:
interface PluginRuntimeContext {
// Plugin identifier
pluginId: string;
// Current settings values
settings: Record<string, string | number | boolean>;
// OpenAPI-Qraft API client
api: ApiClient;
// Current authenticated user (null if not logged in)
currentUser: { user_id: string; role: string } | null;
// Emit custom events
emit: (event: string, payload: unknown) => Promise<void>;
// Subscribe to events (returns unsubscribe function)
on: (event: string, handler: EventHandler) => () => void;
// Plugin-scoped localStorage wrapper
storage: {
get: (key: string) => string | number | boolean | null;
set: (key: string, value: string | number | boolean) => void;
remove: (key: string) => void;
};
}const MyComponent: PluginSlotComponent<Props> = ({ context }) => {
const { data } = context.api.api.listThreads.useQuery({
query: { category_id: "123", limit: 5 },
});
return <div>{data?.threads.map(t => <span key={t.id}>{t.title}</span>)}</div>;
};// Store data
context.storage.set("lastVisit", Date.now());
// Retrieve data
const lastVisit = context.storage.get("lastVisit") as number;
// Remove data
context.storage.remove("lastVisit");cd /path/to/zapbb/frontend
bun add my-zapbb-pluginOr link locally during development:
cd /path/to/my-zapbb-plugin
bun link
cd /path/to/zapbb/frontend
bun link my-zapbb-plugin// frontend/plugins.config.ts
import type { PluginDefinition } from "@/lib/plugins/types";
import myPlugin from "my-zapbb-plugin";
export const plugins: PluginDefinition[] = [
myPlugin,
// Add more plugins here
];bun run build- Use lazy imports for slot components to enable code splitting
- Avoid heavy computations in slot components; defer to effects or workers
- Minimize re-renders by memoizing expensive calculations
- Never expose secrets in client-side code
- Validate all inputs from settings and user interactions
- Use the API client for authenticated requests instead of raw fetch
- Specify version constraints in
minForumVersion/maxForumVersion - Test with multiple ZapBB versions before publishing
- Handle missing context gracefully (settings may not exist)
- Use TypeScript for type safety
- Export types from your package for consumers
- Document your plugin with a README and examples
- Follow ZapBB naming conventions (kebab-case for IDs)
// src/index.ts
import { definePlugin } from "@zapbb/plugin-types";
export default definePlugin({
id: "reaction-counter",
name: "Reaction Counter",
version: "1.0.0",
description: "Shows reaction counts on posts",
author: "ZapBB Team",
slots: {
"post.footer": () => import("./slots/ReactionCount"),
},
events: {
"post.created": async (payload, context) => {
if (context.settings.logNewPosts) {
console.log("New post:", payload);
}
},
},
settingsSchema: {
type: "object",
properties: {
showEmoji: {
type: "boolean",
title: "Show Emoji",
default: true,
},
logNewPosts: {
type: "boolean",
title: "Log New Posts",
default: false,
},
},
},
});// src/slots/ReactionCount.tsx
import type { PluginSlotComponent } from "@zapbb/plugin-types";
interface Props {
post: { id: string; reactions_count: number };
}
const ReactionCount: PluginSlotComponent<Props> = ({ post, context }) => {
const emoji = context.settings.showEmoji ? "👍 " : "";
return (
<span className="text-sm text-slate-600">
{emoji}{post.reactions_count} reactions
</span>
);
};
export default ReactionCount;- Documentation: ZapBB Docs
- Issues: GitHub Issues
- Discussions: GitHub Discussions