WorkPro is our multi-tenant company management platform. It is a single Next.js 15 App Router application on MongoDB, and it bundles nine modules: website and CMS, access control, projects, HRIS, CRM with a client portal, finance, recruitment, communications, and a platform layer. Several features have to know about all of them. Creating an organization sets up defaults for every module, the dashboard shows something from each one, and ⌘K search covers all of their data. This article explains how we kept those shared features from importing everything, using a small set of typed registries.
Why registries instead of a shell that knows everything
The obvious dashboard is a page that imports HR stats, finance stats and CRM stats and renders them in order. That works for one team. WorkPro's modules were built in parallel against fixed contracts, though, and if every author had edited the same dashboard, provisioning service and search endpoint, we would have spent the project resolving merge conflicts.
So the shared features loop over arrays and never name a module. Each module exports one typed contribution from a file it owns, and docs/CONVENTIONS.md states the rule:
Dashboard widget: export
const widget: DashboardWidget… fromcomponents/dashboard/<module>-widget.tsx. This is a server component. Do not edit the registry.
The lead developer adds that export to one registry file. The same shape, a contract type plus an array or record, covers five extension points: provisioning hooks, demo seeders, dashboard widgets, search providers, and import adapters.
Provisioning hooks and seeders: idempotent by contract
A new organization needs leave policies, board stages, lead stages, email templates and more. Each module provides a ProvisionHook:
/**
* Per-module defaults run when an organisation is created (and by `seedOrgDefaults` to repair one).
* Every hook must be idempotent (upserts / $setOnInsert) and use the session it is given.
*/
export type ProvisionHook = (tenantId: Types.ObjectId, session?: ClientSession) => Promise<void>;
export const PROVISION_HOOKS: ProvisionHook[] = [provisionHr, provisionCrm, provisionProjects, provisionFinance, provisionComms];
The type carries two rules. A hook must accept a ClientSession, because provisioning runs inside a MongoDB transaction. If the comms hook fails, the tenant, its first admin and every earlier hook's writes all roll back. A hook must also be idempotent. The consumer is short:
/** Defaults every organisation needs. Idempotent, so it doubles as a repair step. */
export async function seedOrgDefaults(tenantId: Types.ObjectId, session?: ClientSession): Promise<void> {
await seedSystemRoles(tenantId, session);
for (const hook of PROVISION_HOOKS) await hook(tenantId, session);
}
Idempotency gives us a repair step for free. The HR hook, for example, upserts leave policies by a stable key with $setOnInsert, so it never overwrites an organization's edits. When a module later ships a new default, running seedOrgDefaults against an existing organization fills in the gap and leaves everything else alone. The demo seed script calls it on every run.
Demo data follows the same pattern through SEEDERS: Seeder[], where a Seeder is { name, run(env) }. Each seeder has to skip data that already exists, which is why npm run seed is safe to re-run.
Dashboard widgets that check their own access
Widgets are async server components with a small contract:
export type DashboardWidget = {
key: string;
module?: ModuleKey;
visible: (ctx: TenantCtx) => boolean;
Component: (props: { ctx: TenantCtx }) => Promise<React.ReactElement | null> | React.ReactElement | null;
};
export const DASHBOARD_WIDGETS: DashboardWidget[] = [aiBriefing, projects, hr, finance, crm, recruitment, comms];
The dashboard page applies two filters, the organization's module toggles and the widget's own visibility check:
const widgets = DASHBOARD_WIDGETS.filter((w) => (!w.module || ctx.tenant.modules.includes(w.module)) && w.visible(ctx));
The permission rule lives in the module that understands it. The recruitment widget shows for anyone who can read jobs or candidates:
export const widget: DashboardWidget = {
key: 'recruitment',
module: 'recruitment',
visible: (ctx) => can(ctx.user, 'jobs.read') || can(ctx.user, 'candidates.read'),
Component: async ({ ctx }) => {
const s = await recruitmentStats(ctx);
// … stat cards linking into /recruitment
The dashboard has no idea what a requisition is, and we want it that way.
Omni-search: fan out, and isolate failures
A SearchProvider declares a type, a label, an optional module, an allowed(ctx) check and a search(ctx, q, limit) function. The contract makes each provider apply the caller's record scope (Own, Team, Department or All) through the shared scopeFilter helper, so a Sales Associate only finds the leads they may see.
The runner queries eligible providers in parallel. The important part is the try/catch around each one:
const providers = SEARCH_PROVIDERS.filter((p) => (!p.module || ctx.user.role === 'SUPER_ADMIN' || ctx.tenant.modules.includes(p.module)) && p.allowed(ctx));
const results = await Promise.all(
providers.map(async (p) => {
try {
const hits = (await p.search(ctx, q, perType)).sort((a, b) => b.score - a.score).slice(0, perType);
return { type: p.type, label: p.label, hits };
} catch (err) {
console.error('[search] provider failed', p.type, err);
return { type: p.type, label: p.label, hits: [] };
}
}),
);
return results.filter((g) => g.hits.length).sort((a, b) => (b.hits[0]?.score ?? 0) - (a.hits[0]?.score ?? 0));
(Condensed from lib/search/run.ts; query trimming is omitted.)
Without that isolation, Promise.all would reject the whole search the moment one provider threw, and a bug in one module's search would break ⌘K everywhere. With it, the user still gets the other groups and the failure is logged. Groups are then ordered by their best score, so the most relevant module comes first wherever it sits in the registry.
Import adapters: let the compiler do the counting
The AI import hub turns spreadsheets, PDFs, Word documents or images into staged rows for ten record kinds. A user reviews the rows and commits them, and can roll the batch back later. Both the definitions and the server adapters are keyed by ImportModuleKey, a union derived from the IMPORT_MODULES tuple. This registry is a Record, not an array:
/** Server adapters per import (validation, commit, rollback), implemented by each module. */
export const IMPORT_ADAPTERS: Record<ImportModuleKey, ImportAdapter> = {
EMPLOYEES: employees,
ATTENDANCE: attendance,
LEAVE: leave,
CLIENTS: clients,
LEADS: leads,
PROJECTS: projects,
TIME_LOGS: time_logs,
EXPENSES: expenses,
INVOICES: invoices,
CANDIDATES: candidates,
};
If someone adds 'QUOTES' to IMPORT_MODULES and forgets the adapter, tsc fails on this object before anything ships. An array can't give you that. To choose between the two, ask whether every key needs exactly one implementation. Widgets and search providers are open sets; imports are a closed one.
The ImportAdapter contract also tells adapters to commit through the module's own services, "so permissions, numbering, audit and notifications still apply". Imported data gets no shortcut.
Trade-offs
- One hand-edited file per registry. We chose this over file globbing or decorators. It costs one line per module and keeps import cycles easy to see.
- Order is implicit. Arrays run in order. Provisioning hooks don't depend on each other today, but nothing enforces that.
- Idempotency is a convention, not a type.
ProvisionHookcan't prove that a hook uses$setOnInsert. Integration tests against a real MongoDB replica set and code review cover the gap. - Swallowed search errors can hide bugs. To the user, a provider that failed looks like one that found nothing. That is fine for a command palette and would be wrong for a report.
- One more hop. Finding out where a dashboard card comes from takes an extra lookup.
Wrapping up
The pattern is simple: a contract type, a registry, and consumers that only loop. Its value came from using it the same way for provisioning, seeding, the dashboard, search and imports. Every module plugs in the same way, every access check sits next to the code it protects, and the compiler catches the gaps it is able to catch.
The WorkPro case study covers the rest of the architecture, and the code is available in our marketplace. You can also try the live demo, which is read-only and has one-click demo logins. If you are planning a modular SaaS product, this is the kind of work our SaaS engineering team does.



