Full Example
A complete module from scaffold to running app.
This page walks through a complete, minimal tasks module: scaffold, schema, permissions, backend route, frontend page, and the two lines you add to your app to wire it all in.
1. Scaffold
crudy module create tasks
cd tasksYou get the layout described in Module Anatomy.
2. Define the schema
Create database/migrations/tasks_0001_schema.sql in the module package:
CREATE TABLE IF NOT EXISTS app.tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES morbac.orgs(id) ON DELETE CASCADE,
title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 200),
done BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS tasks_org_idx ON app.tasks (org_id);The framework will run this file after the core migrations because the module declares it under migrations.
3. Wire the backend module
src/backend/index.ts:
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import type { BackendModule } from '@crudy/backend';
const __dirname = dirname(fileURLToPath(import.meta.url));
export const TasksBackend: BackendModule = {
manifest: { id: 'tasks', name: 'Tasks', version: '0.1.0' },
migrations: [
join(__dirname, '../../database/migrations/tasks_0001_schema.sql'),
],
morbacRequirements: {
activities: [
{ name: 'read', description: 'Read access' },
{ name: 'create', description: 'Create access' },
{ name: 'update', description: 'Update access' },
{ name: 'delete', description: 'Delete access' },
],
views: [
{ name: 'tasks', description: 'Tasks resource' },
],
},
routes: [
{ method: 'GET', path: '/tasks', resource: 'tasks', permission: { activity: 'read', view: 'tasks' } },
{ method: 'POST', path: '/tasks', resource: 'tasks', permission: { activity: 'create', view: 'tasks' } },
{ method: 'PATCH', path: '/tasks', resource: 'tasks', permission: { activity: 'update', view: 'tasks' } },
{ method: 'DELETE', path: '/tasks', resource: 'tasks', permission: { activity: 'delete', view: 'tasks' } },
],
quotas: [
{ key: 'records', table: 'app.tasks', orgColumn: 'org_id', routePath: '/tasks', description: 'Maximum number of tasks' },
],
auditedTables: [
{ table: 'app.tasks', resourceType: 'task' },
],
};The four routes are proxied to PostgREST against the app.tasks table; the morbac extension applies row-level filtering by org for any user that holds the matching activity-on-view rule.
4. Wire the frontend module
src/frontend/index.ts:
import type { FrontendModule, ModulePageProps } from '@crudy/frontend';
import { ListChecks } from 'lucide-react';
function TaskListPage({ token, effectiveOrgIds, Card, CardHeader, CardTitle, CardContent }: ModulePageProps) {
const orgId = effectiveOrgIds[0];
if (!orgId) return null;
return (
<Card>
<CardHeader><CardTitle>Tasks</CardTitle></CardHeader>
<CardContent>{/* fetch /data/tasks with token + X-Org-Id */}</CardContent>
</Card>
);
}
export const TasksFrontend: FrontendModule = {
manifest: { id: 'tasks', name: 'Tasks', version: '0.1.0' },
navItems: [
{ href: '/tasks', label: 'Tasks', icon: ListChecks },
],
routes: [
{ path: '/tasks', Component: TaskListPage },
],
};The page uses the host UI primitives (Card, CardHeader, etc.) that arrive via ModulePageProps. That keeps the module visually consistent with whatever app embeds it.
For data calls, always pass credentials: 'include' on fetch, send the bearer token in the Authorization header, and add X-Org-Id for any org-scoped resource.
5. Get the module added to your license
A module only runs if your license enables it. Email Crudy or open a request from /pricing with:
- The
audiencefrom yourapp.config.json. - The module id you want enabled (here,
tasks). - The quota you need per org (here,
records).
We send back a refreshed license.lic. Drop it in place of the existing one and restart the backend.
Missing module id, the module not enabled, or a missing limit key all block the module. See Quotas for the limit-value rules.
6. Wire the module into the app
apps/my-app/backend/server.ts:
import { createCrudyServer } from '@crudy/backend';
import { TasksBackend } from '@crudy-modules/tasks/backend';
import appConfig from '../app.config.json' with { type: 'json' };
await createCrudyServer({ modules: [TasksBackend], appConfig });apps/my-app/frontend/app/providers.tsx:
"use client";
import type { ReactNode } from "react";
import { CrudyProviders } from "@crudy/frontend";
import type { AppConfig } from "@crudy/frontend";
import { TasksFrontend } from "@crudy-modules/tasks/frontend";
import rawConfig from "../../app.config.json";
const appConfig = rawConfig as AppConfig;
const MODULES = [TasksFrontend];
export function AppProviders({ children }: { children: ReactNode }) {
return (
<CrudyProviders modules={MODULES} appConfig={appConfig}>
{children}
</CrudyProviders>
);
}7. Reset, restart, log in
make dev-reset
make dev-up
crudy admin resetLog in at http://localhost:3001/login, grant a user the create/tasks rule on an org, and the Tasks page appears in the sidebar.