This tutorial guides you through creating, testing, and packaging a custom plugin for the Veap ecosystem.
We will build @acme/newsletter-plugin—a modular feature that contributes:
- An email subscriber database table with scoped migrations.
- A public virtual subscription route (
/newsletter). - An administrative dashboard widget displaying subscriber metrics.
- An admin sidebar navigation link under
/admin.
Step 1: Scaffold the plugin directory#
Use the veap make:plugin command from your application root to generate a standard plugin skeleton:
bun veap make:plugin newsletterThis creates plugins/newsletter-plugin/ with the recommended layout and configures "plugins/*" in your root package.json workspaces.
plugins/newsletter-plugin/
├── src/
│ ├── index.ts # Primary entry point implementing IPlugin
│ ├── client.ts # Client-safe exports and React components
│ ├── migrations/ # Scoped database migrations
│ ├── models/ # Active Record models
│ └── ui/ # Dashboard widgets and pages
├── package.json
└── tsconfig.jsonStep 2: Define the Subscriber model and migration#
1. Database migration#
Create a reversible schema migration in plugins/newsletter-plugin/src/migrations/0001_create_subscribers_table.ts:
// src/migrations/0001_create_subscribers_table.ts
import { type Migration, Schema } from "@veap/database";
export const createSubscribersTable: Migration = {
name: "20261018000000_create_subscribers_table",
async up() {
await Schema.create("newsletter_subscribers", (table) => {
table.increments("id").primary();
table.string("email").notNullable().unique();
table.string("status").notNullable().defaultTo("active");
table.timestamps(true, true);
});
},
async down() {
await Schema.dropIfExists("newsletter_subscribers");
},
};Export the migration list in src/migrations/index.ts:
// src/migrations/index.ts
import { createSubscribersTable } from "./0001_create_subscribers_table";
export const newsletterMigrations = [createSubscribersTable];2. Active Record model#
Create the Subscriber model in src/models/subscriber.ts:
// src/models/subscriber.ts
import { Model } from "@veap/database";
export class Subscriber extends Model {
static override table = "newsletter_subscribers";
declare id: number;
declare email: string;
declare status: string;
declare created_at: string;
declare updated_at: string;
}Step 3: Create the dashboard widget#
Create an analytics card component in src/ui/newsletter-stats-widget.tsx:
// src/ui/newsletter-stats-widget.tsx
import { Subscriber } from "../models/subscriber";
export async function NewsletterStatsWidget() {
const count = await Subscriber.query()
.where("status", "active")
.count();
return (
<div className="rounded-xl border bg-card p-6 shadow-sm">
<h3 className="text-sm font-medium text-muted-foreground">
Newsletter Subscribers
</h3>
<p className="mt-2 text-3xl font-bold">{count}</p>
<span className="text-xs text-emerald-600">Active and subscribed</span>
</div>
);
}Step 4: Implement the IPlugin entry point (src/index.ts)#
Tie everything together in src/index.ts. Implement the IPlugin interface and use dependency injection in the init hook to register menu items and dashboard widgets:
// src/index.ts
import {
type IPlugin,
type MenuService,
type PluginManifest,
type WidgetService,
} from "@veap/plugins";
import { Inject } from "@veap/kernel";
import { newsletterMigrations } from "./migrations/index";
import { NewsletterStatsWidget } from "./ui/newsletter-stats-widget";
import pkg from "../package.json";
export class NewsletterPlugin implements IPlugin {
manifest: PluginManifest = {
id: "newsletter-plugin",
name: "Newsletter",
version: pkg.version ?? "0.1.0",
description: "Email newsletter management and subscriber widgets.",
};
migrations = newsletterMigrations;
async init(
@Inject("MenuService") menuService: MenuService,
@Inject("WidgetService") widgetService: WidgetService,
): Promise<void> {
// 1. Register admin sidebar navigation
menuService.addAdminItem("marketing", {
id: "newsletter-subscribers",
label: "Subscribers",
url: "/admin/newsletter",
priority: 20,
});
// 2. Register dashboard widget
widgetService.addWidget({
id: "newsletter:stats",
name: "Newsletter Stats",
area: "admin:dashboard",
component: NewsletterStatsWidget,
defaultColSpan: 1,
defaultRowSpan: 1,
priority: 50,
});
}
async onEnable(): Promise<void> {
// Lifecycle hook called when administrator enables the plugin
}
async onDisable(): Promise<void> {
// Lifecycle hook called when administrator disables the plugin
}
}
export default new NewsletterPlugin();Step 5: Test and enable the plugin#
Regenerate your application's static plugin registry:
bun veap plugins:syncStart the development server:
bun dev- The plugin database migrations execute automatically during startup.
- Visit
/adminto verify thatNewsletterStatsWidgetrenders in the admin dashboard widget area. - Check the admin navigation sidebar to confirm the
Subscribersmenu link appears underMarketing.
Step 6: Distribute and eject#
- Distributing: Publish your plugin to npm as
@acme/newsletter-plugin. Other developers install it withbun veap add @acme/newsletter-plugin. - Ejecting: If your application consumes a third-party plugin from npm and you need full source-level customization, run
bun veap eject <package>to clone its source code directly into your localplugins/workspace.