Building a Todo Module with Custom Fields
This tutorial walks you through creating a custom module with a Todo entity, custom fields using the entities module, seed data, and backend pages using the query engine.
What you'll build
- New entity
Todoin your module'sdata/entities.ts(table:todos). - Custom fields defined in your module's
ce.tsunderentities[].fields:priority(integer, 1–5)severity(select: low/medium/high)blocked(boolean)
- Backend page at
/backend/my_todos/todosusing the query engine to project custom fields. - CLI seeding command to insert demo rows and custom field values.
Step 1: Create the module package
Create a new package for your module:
packages/my-todos/package.json
{
"name": "@saasframe/my-todos",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": { "./modules/*": "./src/modules/*" }
}
Step 2: Define the entity
Create packages/my-todos/src/modules/my_todos/data/entities.ts:
import { Entity, Property, PrimaryKey } from '@mikro-orm/core'
@Entity({ tableName: 'todos' })
export class Todo {
@PrimaryKey()
id!: number
@Property()
title!: string
@Property({ default: false })
is_done: boolean = false
@Property()
tenant_id!: string
@Property()
organization_id!: string
@Property({ nullable: true })
deleted_at?: Date
}
Step 3: Define custom entity and fields
Create packages/my-todos/src/modules/my_todos/ce.ts:
export const entities = [
{
id: 'my_todos:todo',
label: 'Todo',
showInSidebar: false,
fields: [
{ key: 'priority', kind: 'integer', label: 'Priority', required: false, indexed: true, filterable: true, formEditable: true },
{ key: 'severity', kind: 'select', label: 'Severity', required: false, filterable: true, formEditable: true, options: ['low', 'medium', 'high'] },
{ key: 'blocked', kind: 'boolean', label: 'Blocked', required: false, filterable: true, formEditable: true },
],
},
]
Step 4: Create the module index
Create packages/my-todos/src/modules/my_todos/index.ts:
import type { ModuleInfo } from '@saasframe/shared/modules/registry'
export const metadata: ModuleInfo = {
id: 'my_todos',
title: 'My Todos',
version: '0.1.0',
description: 'A todo list module with custom fields',
} as any
Step 5: Enable the module
Add the module to apps/saasframe/src/modules.ts:
export const enabledModules = [
// ... existing modules
{ id: 'my_todos', from: '@saasframe/my-todos' },
]
Step 6: Generate and migrate
Run the following commands:
# Generate modules and DI
yarn generate
# Create migrations and apply
yarn db:generate
yarn db:migrate
# Sync custom entity definitions for your tenant
yarn saasframe entities install --tenant <tenantId>
Notes
- The page uses the query engine with
organizationIdset from auth and fields like[id, title, tenant_id, organization_id, is_done, 'cf:priority', 'cf:severity', 'cf:blocked']so custom fields are joined and projected. - You can filter or sort on base fields today; custom-field filters are supported as
filters: [{ field: 'cf:priority', op: 'gte', value: 3 }]or, using the Mongo-style object syntax:filters: { 'cf:priority': { $gte: 3 } }. For sorting, prefer theSortDirenum:sort: [{ field: id, dir: SortDir.Asc }]. - Soft delete: if the
todostable hasdeleted_at, results exclude soft-deleted rows by default. PasswithDeleted: trueto include them. - The entity includes both
organization_idandtenant_idfor proper multi-tenant scoping. All queries must include bothorganizationIdandtenantIdin query options.
Create form (mobile-friendly)
The create page can use the shared CrudForm component for a quick, validated form with custom fields.
Key behaviors:
-
Mobile screens render a fullscreen form with a header and back link.
-
Inline help text per field uses the
descriptionproperty.
Example usage:
<CrudForm
title="Create Todo"
backHref="/backend/my_todos/todos"
schema={todoCreateSchema}
fields={fields}
submitLabel="Create Todo"
cancelHref="/backend/my_todos/todos"
successRedirect="/backend/my_todos/todos"
onSubmit={async (vals) => { /* ... */ }}
/>
Advanced editors supported out of the box: tags, richtext, relation, plus custom renderers for bespoke components. See Admin forms with CrudForm for the full API.
Optional page metadata
You can co-locate admin navigation and access control metadata with the page.
Add next to the page file:
// packages/my-todos/src/modules/my_todos/backend/todos/page.meta.ts
import type { PageMetadata } from '@saasframe/shared/modules/registry'
export const metadata: PageMetadata = {
requireAuth: true,
pageTitle: 'Todos',
pageGroup: 'My Todos',
pageOrder: 20,
}
Or, since this page is a server component, export metadata from the page itself:
// packages/my-todos/src/modules/my_todos/backend/todos/page.tsx
import type { PageMetadata } from '@saasframe/shared/modules/registry'
export const metadata: PageMetadata = {
requireAuth: true,
pageTitle: 'Todos',
pageGroup: 'My Todos',
}
export default async function Page() { /* ... */ }
If both exist, the separate *.meta.ts file takes precedence.
Filters with custom fields
To keep pages consistent and avoid forgetting to wire dynamic filters, the DataTable can auto-append filter controls for filterable custom fields.
Usage:
<DataTable
columns={columns}
data={rows}
// Built-in filter bar (search + filters)
searchValue={search}
onSearchChange={setSearch}
filters={[{ id: 'created_at', label: 'Created', type: 'dateRange' }]}
filterValues={values}
onFiltersApply={setValues}
onFiltersClear={() => setValues({})}
// Auto-include custom-field filters for this entity
entityId="my_todos:todo"
/>
This appends controls for all custom fields marked filterable in their definitions (boolean -> checkbox, select -> dropdown; multi-select uses a checkbox list, text-like kinds -> text input). Selected values should be mapped to query params as cf_<key> or cf_<key>In for multi.
Note: customFieldFiltersEntityId has been renamed to entityId.
Organization scope refresh
When a user changes the active organization from the global switcher, the app emits a browser event so client components can immediately refetch their data without waiting for a full page reload. Instead of wiring the event manually, reach for the hooks in @/lib/frontend/useOrganizationScope:
import { useOrganizationScopeVersion } from '@/lib/frontend/useOrganizationScope'
import { useQuery } from '@tanstack/react-query'
export function TodosTable() {
const scopeVersion = useOrganizationScopeVersion()
const { data } = useQuery({
queryKey: ['todos', params, scopeVersion],
queryFn: fetchTodos,
})
// ...
}
useOrganizationScopeVersion() increments whenever the organization changes, making it easy to append to query keys, effect dependency arrays, or SWR keys. If you need the selected organization id directly, call useOrganizationScopeDetail() which returns { organizationId }.
DataTable already subscribes internally and calls router.refresh(), so any list rendered with it will refresh as soon as the user picks another organization. Use the hooks when you have custom fetch logic (e.g. React Query, SWR, or manual effects) that should re-run on organization changes.