Modules

A module is a feature the cms does not have, built beside it and not into it: a work log, a booking book, a register of members, anything with rows of its own that staff create and update, list and show on the site. A module lives in a folder of your site, is listed in site.config.ts like a block package, and brings:

  • tables of its own, with migrations;
  • API routes;
  • an entry in the admin's sidebar with a screen behind it, and cards on the dashboard;
  • pages drawn from code;
  • fields on the page kinds it names, and blocks.

By the end of this chapter you will have a working module called Work: a list of items with a title, notes and a due date, which staff manage in the admin and which the site can list on any page. This chapter assumes you can read TypeScript and a little React.

Make a module

  1. In your site's folder, generate a module called work:
pnpm cms generate module work

It creates the folder modules/work. Everything you need is in it:

modules/work/
  index.ts                  the table, migrations, services, routes and pages
  admin.tsx                 the admin screen, sidebar entry and dashboard card
  pages/index.tsx           the page at /work
  blocks/                   a block package, with a “Work” source for “List of…”
  README.md
  1. List the module in site.config.ts. A module that comes from a package is listed by its package name instead, such as '@acme/cms-work':
site.config.ts
modules: ['./modules/work'],
  1. Create the module's table, then restart the site:
pnpm cms migrate

Restart pnpm dev with Ctrl+C and run it again. Now look around:

  • The admin has a Work entry in the sidebar, after Users. Its screen adds items, ticks them off and deletes them.
  • The Dashboard shows a card of what is due this week.
  • The page /work lists the open items. It is for signed-in editors and admins.
  • “Work” is a source in the “List of…” block, so editors can show the items on any page without writing a block.

Rename and extend it from here. The rest of this chapter walks through the pieces.

What the files do

Each file runs in a different place, and that decides what it may import:

FileRuns inWhat it declares
index.tsthe API and the command linedefineModule({ id, label, migrations, services, routes, adminRoutes, pageFields, pages, html, jobs })
pages/*.tsxthe site, on the serverdefinePage({ title, render }), the React behind each page, loaded lazily
admin.tsxthe admin, in the browserdefineModuleAdmin({ id, nav, screen, dashboard })
blocks/the site and the editoran ordinary block package, registered when it is there

Two rules follow:

  • index.ts never imports React. The command line runs it directly for cms migrate and cms seed, and it cannot read JSX. A page's React sits behind page: () => import('./pages/index.js'), so the command line never loads it.
  • blocks/ never imports server code. The editor loads every block package in the browser, collections included. A collection reaches the module through the request's services, and the only thing it may take from index.ts is a type.

The id is the namespace

The module's id is a lowercase name that starts with a letter. Everything the module owns is named from it:

WhatWhere, for the id work
tableswork_*, by convention
migrationswork:0001_items, tracked after the cms's own
routes for the site/api/modules/work/…
routes for the admin/api/admin/modules/work/…, for editors and up
admin screen/admin/work
pagesthe paths the module declares, such as /work; no content page can take one
collection and blockswork and work/<Name>

The id must be unique on the site and cannot be a name the admin already uses, such as pages, media, users, settings or collections, or the name of a feature, such as posts, forms, shop, polls or appointments. The site refuses to start with CONFIG_INVALID if the id, a page path or a page field clashes.

Tables and migrations

A migration creates or changes tables. The generated module's first one creates work_items:

modules/work/index.ts
import { columnTypes, defineModule, type CmsMigration } from '@jamiemcconnell/cms/modules';

export interface WorkItemTable {
  id: string;
  title: string;
  notes: string;
  due_on: string | null; // YYYY-MM-DD
  done: 0 | 1 | boolean; // 0 or 1 on SQLite, a boolean on Postgres
  created_at: number; // epoch milliseconds
  updated_at: number;
}

/** The module's tables by name: what `m.tables()` is typed with. */
export interface WorkTables {
  work_items: WorkItemTable;
}

const migrations: CmsMigration[] = [
  {
    name: '0001_items',
    async up(db, dialect) {
      const t = columnTypes(dialect); // the types that differ between SQLite and Postgres
      await db.schema
        .createTable('work_items')
        .addColumn('id', 'text', (c) => c.primaryKey())
        .addColumn('title', 'text', (c) => c.notNull())
        .addColumn('notes', 'text', (c) => c.notNull().defaultTo(''))
        .addColumn('due_on', 'text')
        .addColumn('done', t.bool, (c) => c.notNull().defaultTo(false))
        .addColumn('created_at', t.time, (c) => c.notNull())
        .addColumn('updated_at', t.time, (c) => c.notNull())
        .execute();
    },
  },
];

Migrations are append-only: to change a table later, add a second migration and never edit the first. They run after the cms's own whenever migrations run, which is cms migrate or, on a server, at start when CMS_AUTO_MIGRATE is true. The site's /api/health counts them as pending until they have run.

The conventions are the cms's own: ids are UUIDv7 text (from uuidv7()), time is epoch milliseconds, money is integer minor units (pence or cents), JSON goes through json.stringify and json.parse, and booleans through bool.toDb and bool.fromDb. A module reads and writes its tables through m.tables(), which also lets it read the cms's tables, but never alter them. To change several rows together, pass the statements to m.db.atomic([…]). Never open an interactive transaction.

Services

The module's logic goes in index.ts once, as services, and everything else reaches it from there. services(m) runs once at startup, and what it returns is m.services in the routes and pages, and in a collection through the request's services. The context m carries the config, the environment, the database, the cms's repositories (pages, products, orders, users, audit…), storage, a logger, the guards, and m.api for the rest.

This is a service that ticks an item off. The generated module has this and more, and the excerpts in this chapter are cut down from its files:

modules/work/index.ts
import { CmsError, bool, type ModuleContext } from '@jamiemcconnell/cms/modules';

export const workServices = (m: ModuleContext<WorkTables>) => ({
  async done(id: string, actorId: string): Promise<void> {
    const result = await m
      .tables()
      .updateTable('work_items')
      .set({ done: bool.toDb(true), updated_at: Date.now() })
      .where('id', '=', id)
      .executeTakeFirst();
    if (Number(result.numUpdatedRows) === 0)
      throw new CmsError('NOT_FOUND', { details: { what: 'work', id } });
    await m.repos.audit.record({ entityType: 'work', entityId: id, action: 'done', actorId });
  },
});

export type WorkServices = ReturnType<typeof workServices>;
export type WorkModule = ModuleContext<WorkTables, WorkServices>;

Throw CmsError with a code from the cms's registry, such as NOT_FOUND, CONFLICT or VALIDATION_FAILED, plus details of your own. The admin shows details.message when there is one. Do not put sentences meant for visitors in API errors; the admin maps codes to words.

Routes

A module has two sets of routes. adminRoutes(m) serves the module's admin screen, at /api/admin/modules/work, and the cms already requires an editor for them. routes(m) serves the site, at /api/modules/work: an island's request, a public feed. It has no guard until you add one:

modules/work/index.ts
import {
  createRoute,
  defineModule,
  jsonBody,
  jsonResponse,
  requireCsrf,
  requireRole,
  router,
} from '@jamiemcconnell/cms/modules';

export default defineModule<WorkTables, WorkServices>({
  id: 'work',
  // …

  adminRoutes: (m) => {
    const r = router();
    r.openapi(
      createRoute({
        method: 'post',
        path: '/items',
        request: { body: jsonBody(workItemInput) },
        responses: { 201: jsonResponse(workItemSchema, 'Created') },
      }),
      async (c) => c.json(await m.services.create(c.req.valid('json'), c.get('user')?.id ?? ''), 201),
    );
    return r;
  },

  routes: (m) => {
    const r = router();
    r.post('/items/:id/done', requireRole('editor'), requireCsrf(() => m.guards), async (c) => {
      await m.services.done(c.req.param('id'), c.get('user')?.id ?? '');
      return c.json({ ok: true });
    });
    return r;
  },
});

Guards are the module's choice: requireAuth() asks for a signed-in visitor, requireRole('editor') for a role, and requireCsrf(() => m.guards) for the CSRF token. Put the last one on anything that writes, as every cms endpoint that a browser posts to does. c.get('user') is the signed-in user, or null. Routes made with createRoute are added to the site's /api/openapi.json, and plain r.get(…) routes work too. A bad request body answers with VALIDATION_FAILED and the list of problems.

The admin screen

admin.tsx runs in the browser and imports from the admin SDK. It gives the module a sidebar entry, a screen at /admin/work that does its own routing inside, and dashboard cards:

modules/work/admin.tsx
import {
  Card,
  DataTable,
  PageHeader,
  Route,
  Routes,
  defineModuleAdmin,
  useModuleApi,
  useQuery,
} from '@jamiemcconnell/cms/modules/admin';
import type { WorkItem } from './index.js'; // types only

const ItemsScreen = () => {
  const api = useModuleApi('work');
  const items = useQuery({
    queryKey: ['module', 'work', 'items'],
    queryFn: () => api.get<WorkItem[]>('/items'),
  });
  return (
    <>
      <PageHeader title="Work" />
      <Card>
        {items.data ? (
          <DataTable
            rows={items.data}
            rowKey={(i) => i.id}
            columns={[{ key: 'title', header: 'Title', render: (i) => i.title }]}
          />
        ) : null}
      </Card>
    </>
  );
};

export default defineModuleAdmin({
  id: 'work',
  nav: { label: 'Work', minRole: 'editor' },
  screen: () => (
    <Routes>
      <Route index element={<ItemsScreen />} />
    </Routes>
  ),
  dashboard: [],
});

The sidebar lists module entries after Users, for roles of at least nav.minRole, which is editor unless you say otherwise. dashboard lists components that the Dashboard draws after its own cards; return null from one when it has nothing to say.

useModuleApi('work') is a JSON client for the module's admin routes, with get, post, put, patch and del. It sends the session cookie and, on a failure, throws the API's { code, status, details }, which errorMessage(error) turns into words for a toast or an alert.

Everything else the screen needs comes from the same import, so a module adds no dependencies:

  • the admin's own components: Button, Card, DataTable, Field, Input, Select, Textarea, Checkbox, Dialog, ConfirmDialog, Tabs, Pagination, Alert, Loading, EmptyState, Badge and useToast;
  • data hooks: useQuery, useMutation and useQueryClient;
  • routing: Routes, Route, Link, NavLink, Navigate, useParams, useNavigate and useSearchParams;
  • useAdmin() for the signed-in user and the site's facts, such as locale and currency, plus formatDate, formatMoney, parseMajor, toMajor, slugify and useDebounced.

A module that stores pictures or files uses the media library and not uploads of its own. MediaPickerDialog picks one image from the library, useUpload() uploads a file and returns its record, useMedia(search) lists the library, useMediaMutations(id) edits alt text or deletes, and thumbUrl(media, width) gives the resized address of a thumbnail. For rich text, RichTextEditor is the editor the cms's own forms use: it takes value as HTML and calls onChange(html).

Show the rows on the site

The natural way to show a module's rows is a collection. The generated module has one, in blocks/collections/work.ts. Editors then build a list from the core “List of…” block, with “Item field” and “Item link” blocks inside it, or even a dashboard of what is due, filtered and sorted. No block code is needed.

The collection's load runs on the server at request time and finds the module through the request's services. The editor only ever calls sample:

modules/work/blocks/collections/work.ts
import { defineCollection, z, type CollectionItem } from '@jamiemcconnell/cms/blocks';
import { selectField } from '@jamiemcconnell/cms/blocks-core';
import type { WorkItem, WorkModule } from '../../index.js'; // types only: no server code runs in the browser

const filtersSchema = z.object({ status: z.enum(['open', 'done', 'all']).default('open') });

const item = (row: WorkItem): CollectionItem => ({
  id: row.id,
  href: '',
  fields: { title: row.title, dueOn: row.dueOn ?? '' },
});

export const workCollection = defineCollection({
  name: 'work',
  label: 'Work',
  fields: [
    { name: 'title', type: 'text', label: 'Title' },
    { name: 'dueOn', type: 'date', label: 'Due' },
  ],
  filters: { status: selectField('Show', ['open', 'done', 'all']) },
  filtersSchema,
  sorts: [{ value: 'due', label: 'Soonest first' }],
  load: async ({ filters, limit, skip }, ctx) => {
    const module = ctx.services.modules?.['work'] as WorkModule | undefined; // server only
    if (!module) return [];
    const { status } = filtersSchema.parse(filters);
    const rows = await module.services.list({ status, limit: skip + limit });
    return rows.slice(skip, skip + limit).map(item);
  },
  sample: () =>
    item({
      id: 'sample',
      title: 'A sample item',
      notes: '',
      dueOn: '2026-12-31',
      done: false,
      createdAt: 0,
      updatedAt: 0,
    }),
});

A page that lists a module's rows can be cached and shared between visitors, unless the collection says access: 'user'.

To write a block of your own for the module, put it in the module's block package:

pnpm cms generate block Board --package ./modules/work/blocks
pnpm cms generate block Tick --island --package ./modules/work/blocks

See Your own blocks for how a block is written. A block that changes rows is an island. It posts to the module's routes with the CSRF token, exactly as the basket does:

modules/work/blocks/tick/island.tsx
import { CSRF_HEADER, csrfToken } from '@jamiemcconnell/cms/blocks-core/lib';

const tick = async (id: string) => {
  const res = await fetch(`/api/modules/work/items/${id}/done`, {
    method: 'POST',
    credentials: 'include',
    headers: { [CSRF_HEADER]: await csrfToken(), accept: 'application/json' },
  });
  if (!res.ok) throw await res.json(); // { code, status, details }
};

The route behind it requires a session and the CSRF token. A signed-out visitor gets UNAUTHENTICATED and a stale token gets CSRF_REJECTED.

An island that shows something about the visitor does not have to fetch it after it loads. It lists the keys it reads in metadataProps: the cms's viewer ({ id, name, email }, or null), profile, cart, orders and claim, or one of the module's own. The server answers them when it draws the page. A personal answer makes the page private, so it is never shared between visitors. Render what arrives, and fetch only when the value is undefined, which happens in the editor, or to double-check an empty one.

A module defines its own keys with metadata, as a function of the module context that runs once at startup:

modules/work/index.ts
metadata: (m) => [
  {
    key: 'assignments', // answered as 'work:assignments'
    load: async ({ user }) =>
      user
        ? { value: await m.services.assignmentsFor(user.id), personal: true }
        : { value: [], personal: false },
  },
],

The island then lists metadataProps: ['work:assignments']. Each provider has a lowercase, hyphenated key, and load receives the viewer, the request URL and its cookies. Only the keys that the page's islands list are loaded.

Pages drawn from code

A module can own pages whose content is code, such as a list and a detail view. The generated module has the list at /work. Declare each path in index.ts, and add a detail page at pages/item.tsx:

modules/work/index.ts
pages: [
  { path: '/work', access: 'editor', page: () => import('./pages/index.js') },
  { path: '/work/:id', access: 'editor', page: () => import('./pages/item.js') },
],

and write what each one draws:

modules/work/pages/item.tsx
import { definePage } from '@jamiemcconnell/cms/modules';
import type { WorkServices, WorkTables } from '../index.js';

export default definePage<WorkTables, WorkServices>({
  title: ({ params }) => `Item ${params['id']}`,
  async render({ module, params, user, url, block }) {
    const item = await module.services.byId(params['id'] ?? '').catch(() => null);
    if (!item) return null; // a 404
    if (url.searchParams.get('done')) return Response.redirect(new URL('/work', url), 303);
    return (
      <section className="wn-section wn-p-lg">
        <div className="wn-container-narrow">
          <h1>{item.title}</h1>
          {block('core/Text', { html: item.notes })}
        </div>
      </section>
    );
  },
});

A page path is absolute and lowercase, with :name parameters: one address or one record, no wildcards. Module pages are matched before content pages, and a content page cannot take a path a module page owns. render returns React, drawn inside the site's default layout, a Response to send instead (a redirect, say), or null for a 404. block(type, props) draws one of the site's own blocks in place, islands included, so a code page can reuse the site's look. The theme's utility classes, such as wn-section, wn-container-narrow and wn-p-lg, are there too.

access works as on a protected page (Pages and blocks). A signed-out visitor to a locked page is sent to sign in and back. A role that falls short sees the access notice with a 403. A page that is not public is never shared between visitors.

More a module can do

Add fields to pages

A module can add fields to the page kinds it names, edited beside the page's own fields and checked when the page is published:

modules/work/index.ts
pageFields: [
  {
    kinds: ['content', 'post'],
    fields: [
      { name: 'workItem', type: 'text', label: 'Work item', required: false },
      { name: 'brief', type: 'richtext', label: 'Brief', required: true },
    ],
  },
],

The field types are text, textarea, richtext, image, date, tags, select (with options), boolean, number and url. A name starts with a lowercase letter and cannot be a setting every page has, such as title or description, or a field another module put on the same kind. Rich text is cleaned of anything unsafe when it is saved.

Add markup to every page

A module can put markup in the <head>, at the start of the <body> and at its end, on every public page, such as an analytics snippet. It runs once at startup and returns any of head, bodyStart and bodyEnd:

modules/work/index.ts
html: (m) => ({
  head: `<script defer data-domain="${new URL(m.config.site.url).hostname}" src="https://plausible.io/js/script.js"></script>`,
}),

The markup is written as it is, after what site.config.ts adds and before what Settings → Site adds.

Run work on a schedule

A module can add scheduled jobs beside the cms's own, such as order expiry:

modules/work/index.ts
import { defineJob } from '@jamiemcconnell/cms/modules';

jobs: (m) => [
  defineJob({
    name: 'digest',
    description: 'Emails the new items of the day to the editors',
    schedule: '0 9 * * *',
    run: async ({ now, log }) => {
      const sent = await m.services.sendDigest(now);
      log.info('digest sent', { sent });
      return { sent };
    },
  }),
],

A job has a lowercase, hyphenated name, listed as work:digest; a five-field cron schedule (minute, hour, day of month, month, day of week) in the server's time zone; and run, which receives the moment it started and a log and may return counts for the log line. A run still going when the next tick comes is skipped, and a run that throws is logged while the schedule carries on.

A server on Node runs jobs inside the server process, from its first request, unless site.config.ts says jobs: { enabled: false }. For a second server, or a host with no long-running process, run one by hand, or from a cron entry of your own:

pnpm cms jobs list              # every job and when it runs
pnpm cms jobs run work:digest   # run one now

Test it

Put tests next to the code as *.test.ts or *.test.tsx under modules/, and pnpm test runs them with the block tests. Blocks and collections test the way the site's own do (Your own blocks).

What a module cannot do

A module cannot change the cms's tables, replace its routes or screens, or reach into another module except through its routes. Settings tabs, email templates and order hooks are not places a module can extend yet. Scheduled jobs are.