Skip to main content

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 Todo in your module's data/entities.ts (table: todos).
  • Custom fields defined in your module's ce.ts under entities[].fields:
    • priority (integer, 1–5)
    • severity (select: low/medium/high)
    • blocked (boolean)
  • Backend page at /backend/my_todos/todos using 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 organizationId set 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 the SortDir enum: sort: [{ field: id, dir: SortDir.Asc }].
  • Soft delete: if the todos table has deleted_at, results exclude soft-deleted rows by default. Pass withDeleted: true to include them.
  • The entity includes both organization_id and tenant_id for proper multi-tenant scoping. All queries must include both organizationId and tenantId in 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 description property.

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.