7.9 KiB
Module System
Modules are self-contained features that can be enabled/disabled via environment variables.
File Structure
src/modules/your-module/
├── module.config.js # Required — navigation, pages, widgets
├── db.js # Database schema (createTables / dropTables)
├── crud.js # CRUD operations
├── actions.js # Server actions (for public pages)
├── metadata.js # SEO metadata generators
├── api.js # API route handlers
├── cron.config.js # Scheduled tasks
├── index.js # Public API re-exports
├── .env.example # Environment variable documentation
├── admin/ # Admin pages (lazy-loaded)
│ └── index.js # Re-exports admin components
├── pages/ # Public pages (lazy-loaded)
│ └── index.js
├── dashboard/ # Dashboard widgets
│ ├── statsActions.js
│ └── Widget.js
└── sub-feature/ # Optional sub-modules (e.g. items/, categories/)
├── db.js
├── crud.js
└── admin/
Not all files are required. Only create what the module actually needs.
Step 1 — Create module.config.js
import { lazy } from 'react';
export default {
// Module identity
name: 'your-module',
displayName: 'Your Module',
version: '1.0.0',
description: 'Description of your module',
// Other modules this one depends on (must be enabled too)
dependencies: ['clients'],
// Environment variables this module uses (documentation only)
envVars: [
'YOUR_MODULE_API_KEY',
],
// Admin navigation — single section object or array of section objects
navigation: {
id: 'your-module',
title: 'Your Module',
icon: 'SomeIcon', // String icon name from shared/Icons.js
items: [
{ name: 'Items', href: '/admin/your-module/items', icon: 'PackageIcon' },
{ name: 'New', href: '/admin/your-module/items/new', icon: 'PlusSignIcon' },
],
},
// Admin pages — path → lazy component
adminPages: {
'/admin/your-module/items': lazy(() => import('./admin/ItemsListPage.js')),
'/admin/your-module/items/new': lazy(() => import('./admin/ItemCreatePage.js')),
'/admin/your-module/items/edit': lazy(() => import('./admin/ItemEditPage.js')),
},
// (Optional) Custom resolver for dynamic paths not known at build time.
// Called before the adminPages map. Return the lazy component or null.
pageResolver(path) {
const parts = path.split('/').filter(Boolean);
// example: /admin/your-module/{type}/list
if (parts[2] === 'list') return lazy(() => import('./admin/ItemsListPage.js'));
return null;
},
// Public pages — keyed by 'default' (one component handles all public routes)
publicPages: {
default: lazy(() => import('./pages/YourModulePublicPages.js')),
},
// Public route patterns for SEO/route matching (relative to /zen/your-module/)
publicRoutes: [
{ pattern: ':id', description: 'View item' },
{ pattern: ':id/pdf', description: 'PDF viewer' },
],
// Dashboard widgets (lazy-loaded, rendered on the admin dashboard)
dashboardWidgets: [
lazy(() => import('./dashboard/Widget.js')),
],
};
Navigation as multiple sections
When a module provides several distinct sections (like the posts module with one section per post type), set navigation to an array:
navigation: [
{ id: 'your-module-foo', title: 'Foo', icon: 'Book02Icon', items: [...] },
{ id: 'your-module-bar', title: 'Bar', icon: 'Layers01Icon', items: [...] },
],
Step 2 — Create db.js
Every module that uses a database must expose a createTables function:
import { query } from '@hykocx/zen/database';
export async function createTables() {
const created = [];
const skipped = [];
const exists = await query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = $1
)`, ['zen_your_module']);
if (!exists.rows[0].exists) {
await query(`
CREATE TABLE zen_your_module (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
)
`);
created.push('zen_your_module');
} else {
skipped.push('zen_your_module');
}
return { created, skipped };
}
export async function dropTables() {
await query(`DROP TABLE IF EXISTS zen_your_module CASCADE`);
}
Never create migrations. Instead, provide the SQL to the user so they can run it manually.
Step 3 — Create .env.example
Document every environment variable the module reads:
#################################
# MODULE YOUR-MODULE
ZEN_MODULE_YOUR_MODULE=false
ZEN_MODULE_YOUR_MODULE_API_KEY=
ZEN_MODULE_YOUR_MODULE_SOME_OPTION=default_value
#################################
Step 4 — Create cron.config.js (optional)
Only needed if the module requires scheduled tasks:
import { doSomething } from './reminders.js';
export default {
jobs: [
{
name: 'your-module-task',
description: 'Description of what this job does',
schedule: '*/5 * * * *', // cron expression
handler: doSomething,
timezone: process.env.ZEN_TIMEZONE || 'America/Toronto',
},
],
};
Step 5 — Register the module in 5 files
modules/modules.registry.js — add the module name
export const AVAILABLE_MODULES = [
'clients',
'invoice',
'your-module',
];
modules/modules.pages.js — import the config
'use client';
import yourModuleConfig from './your-module/module.config.js';
const MODULE_CONFIGS = {
// ...existing modules...
'your-module': yourModuleConfig,
};
modules/modules.actions.js — import server actions (if public pages or dashboard widgets)
import { yourPublicAction } from './your-module/actions.js';
import { getYourModuleDashboardStats } from './your-module/dashboard/statsActions.js';
export const MODULE_ACTIONS = {
// ...existing modules...
'your-module': { yourPublicAction },
};
export const MODULE_DASHBOARD_ACTIONS = {
// ...existing modules...
'your-module': getYourModuleDashboardStats,
};
modules/modules.metadata.js — import metadata generators (if SEO needed)
import * as yourModuleMetadata from './your-module/metadata.js';
export const MODULE_METADATA = {
// ...existing modules...
'your-module': yourModuleMetadata,
};
modules/init.js — register the database initializer
import { createTables as createYourModuleTables } from './your-module/db.js';
const MODULE_DB_INITIALIZERS = {
// ...existing modules...
'your-module': createYourModuleTables,
};
Step 6 — Enable the module
ZEN_MODULE_YOUR_MODULE=true
The environment variable is derived from the module name: ZEN_MODULE_ + module name uppercased (hyphens become underscores).
Sub-modules
For complex modules, group related features into sub-directories. Each sub-module follows the same pattern (db.js, crud.js, admin/). The parent module.config.js registers all sub-module admin pages, and the parent index.js re-exports everything publicly.
See src/modules/invoice/ for a complete example with items/, categories/, transactions/, and recurrences/ sub-modules.
Reference implementations
| Module | Features demonstrated |
|---|---|
src/modules/invoice/ |
Sub-modules, public pages, cron jobs, Stripe, email, PDF, dashboard widgets, metadata |
src/modules/posts/ |
Dynamic config from env vars, pageResolver, multiple navigation sections |
src/modules/clients/ |
Simple module, dependencies, no public pages |