Skip to content

Authoring a module

Chris Taylor-Guest edited this page Oct 16, 2026 · 130 revisions

Authoring a module

This guide covers everything you need to build, migrate, and ship a real Cactus module from scratch. If you follow it end to end you'll have a module that installs, migrates its database tables, and registers its permissions - all without touching the core Cactus codebase.

What is a module?

A module is a git submodule living under modules/<name>/ in the Cactus repo. It adds:

  • Its own prefixed database tables (plain SQL migrations, applied at build time)
  • Its own admin pages (Next.js routes in the module's directory, surfaced via nav entries)
  • Its own permissions (declared in the manifest, appear in the Roles matrix)
  • Optionally: public routes, cookie categories, and required environment variables

The core never imports from a module. A module imports from core utilities but never modifies core tables or the Prisma schema.

The manifest: cactus.module.json

Every module repo must contain cactus.module.json at its root:

{
  "name": "forum",
  "version": "1.0.0",
  "tablePrefix": "forum_",
  "description": "Discussion forums for Cactus Foundation.",
  "requiredEnvVars": [
    { "name": "FORUM_MAX_THREADS", "required": false }
  ],
  "navEntries": [
    {
      "label": "Forum",
      "path": "/forum",
      "icon": "<path d=\"M4 4h16v12H8l-4 4z\"/><path d=\"M8 9h8M8 12h5\"/>",
      "permission": "forum.threads.read"
    }
  ],
  "permissions": [
    "forum.threads.read",
    "forum.threads.write",
    "forum.threads.delete_any",
    "forum.threads.delete_own",
    "forum.posts.write",
    "forum.posts.delete_any",
    "forum.posts.delete_own",
    "forum.moderate"
  ],
  "cookieCategories": []
}

Manifest fields in detail

Field Type Description
name string (required) Unique, lowercase kebab-case (^[a-z][a-z0-9-]*$). Used as the folder name and as the key in ModuleMigration.
version string (required) Semver, e.g. 1.0.0. Must match the latest tagged GitHub release.
tablePrefix string (required) Short unique namespace for this module's tables. Must end with underscore (^[a-z][a-z0-9_]*_$). Example: forum_. Two modules cannot share a prefix.
description string Short description shown in the admin.
requiresCoreVersion string Optional. Minimum Cactus core version (semver, no leading v) this module needs. Install and update are refused with an "update Cactus first" message when the running core is older. Set this whenever your module imports a core helper introduced in a specific core release - without it, installing on an older core commits the module and breaks the site's next build.
requiredEnvVars Array<{name: string, required: boolean}> Env vars this module needs. required: true vars block installation if missing; required: false vars show a warning but don't block. Declaring a var here is also what lets your settings tab write it through /api/admin/env - that route only accepts keys the installed modules have declared. Adding a new one in a later version of your module works from Cactus 0.5.875 onwards, which refreshes the stored manifest when a module updates; before that the manifest was frozen at install time and the new key was silently ignored. If a site's copy of your module is behind, /api/admin/env answers with the unaccepted keys in skipped (or a 400 if it accepted none), so surface that rather than showing a bare "Saved".
navEntries NavEntry[] Admin navigation entries to add for this module. The icon is SVG, not an emoji: give it the inside of a 24x24 viewBox icon - a bare run of <path>, <circle>, <rect> and friends, no <svg> wrapper. The sidebar supplies the wrapper, and with it the viewBox, the stroke width and currentColor, so your icon inherits the theme and matches the core icons beside it. Draw it as a stroked outline (no fill) unless you mean a solid. Anything that is not markup - an emoji, a name, a URL - is ignored and the link draws the generic module puzzle piece instead. Keep it to one. The sidebar is shared with every other module and it does not scale: register a single link and make everything else a tab on that page (see TabStrip, and the *Nav.tsx components in gazette, boards, ultimate-seo and space-planner-for-shop for the pattern). Settings belong in a settingsTabs entry, not a link. A screen that belongs inside another module's page - or core's Inbox or Media - should publish into that page's tab extension point and register no link at all. Two links is for a module the size of Shop; more than two wants a very good reason.
navGroupLabel string Optional. If set, this module's navEntries get their own sidebar section heading (e.g. "Gazette") instead of being bucketed into the shared "Modules" section with every other module's entries. Use this if your module contributes several distinct admin areas (Gazette: Posts/Tags/Series/Authors/Comments/Templates) rather than one single link.
navGroupOrder number Optional, only meaningful alongside navGroupLabel. Lower numbers sort earlier among labelled module sections; modules that omit it sort after any that set it, in their existing order. Use this if your module's section should appear above another module's, rather than wherever install order happens to put it.
permissions string[] Permission keys this module declares. They're seeded into the Permission table on install and appear in the Roles matrix.
cookieCategories Array<string | { key, label?, description? }> Non-essential cookie categories this module sets (e.g. ["analytics"], ["live-chat"]). Declare them as keys, not prose: lowercase, starting with a letter, letters/numbers/hyphens/underscores only - that is the shape the banner stores and your code checks. Anything looser is folded into a valid key when the admin clicks the suggestion ("Live chat" → key live-chat, label Live chat), so an older manifest still works, but the key your code checks must match what actually lands in the site's list. The long form carries the wording too: { "key": "analytics", "label": "Analytics", "description": "Lets us count visits…" }. Prefer it - a chip accepted in the short form lands as a switch on the visitor's banner with no explanation under it, which is a poor thing to ask somebody to agree to. Both forms are supported permanently. These are surfaced as one-click suggestions in the admin consent banner editor. Declaring a category here does not automatically add it to the site's category list - that remains the admin's decision. See the consent gate contract below.
teardown string[] PascalCase names of database tables owned by this module (e.g. ["ForumThread", "ForumPost"]). Required if you want admins to be able to choose "Remove code and data" during uninstall. Without it, only "Remove code only" is available.
puckBlocks PuckBlock[] Optional. Registers Puck blocks provided by this module. See Module Puck blocks below.
settingsTabs SettingsTab[] Optional. Registers tabs your module contributes to the core admin's Settings (/config) page. See Module settings tabs below.
extensionPoints ExtensionPoint[] Optional. Registers components your module contributes to extension points published by another module's own pages (typically a hard dependency from requiresModules). See Module extension points below.
consumesConversationProviders boolean Optional, default false. Declares that your module presents every channel on the site in one place, so core stands down: it hides its own All inbox tab and the core.inbox-tabs entry of every module publishing a core.conversation-provider. It names no module in either direction. Suppression is resolved per user - if this user cannot see your own tab, nothing is hidden from them, so a colleague holding another module's permission and not yours never loses their own messages - and it fails safe on a missing flag, since a freshly installed module's stored manifest may not carry the key until the next deploy.

Reading the map from public-path code: import @/lib/modules/extension-points.public, not @/lib/modules/extension-points. The full map is one flat object holding every contributed extension point, so importing it to read a single point statically pulls in every other point's implementation - including every installed module's admin screens. On a live site that put a 107KB variations panel, a 58KB fabric editor and a 44KB abandoned-carts screen into the bundle of a homepage that renders none of them. extension-points.public.ts is the same map with the components/admin/ entries withheld and is a drop-in replacement (modulePublicExtensionPointComponents). Every point an admin component contributes to is read only from admin screens, so nothing on a public path loses anything by using it. Keep the full map for code that only ever runs on an admin screen. Requires core 0.5.1288. To check whether your module is still on the wrong one, run node scripts/analyse-public-bundle.mjs --offenders in a core checkout with your module installed - it lists every file a public request can reach that still imports the full map, and names the modules that need a release. | smsProviders | Array<{id, label, import, export}> | Optional. Registers an SMS delivery provider with core auth, used to send login codes by text message (admin password login and member SMS 2FA). import/export name a module file exporting an object satisfying core's SmsProvider type (lib/auth/sms.ts): { isConfigured(): boolean \| Promise<boolean>, sendSms(to, body): Promise<void> }. Collected by scripts/generate-module-sms-providers.mjs into the gitignored lib/modules/sms-providers.ts (same generated-file pattern as settingsTabs). Core uses the first configured provider from an active module and silently falls back to email delivery when none is available - a provider must never be load-bearing for login. The Twilio module is the reference implementation. | | emailTemplates | {groupLabel, import, export} | Optional. Registers this module's emails with core's single email editor (Settings → Email → Templates), alongside core's own and every other module's. import/export name a module file exporting an EmailTemplateDef[] (lib/email/registry.ts): {key, label, subject, bodyHtml, mergeTags, requiredTags?, rawTags?, transactional}. Every key must be prefixed with your module's own name (shop.order-confirmed) - core skips any that is not, which is what stops two modules claiming one email. groupLabel is the heading they sit under in the editor. Collected by scripts/generate-module-email-templates.mjs into the gitignored lib/modules/email-templates.ts (same generated-file pattern as settingsTabs). Send with core's renderEmailTemplate(key, vars) + sendEmail(...), or sendTemplateEmail(to, key, vars); a null render means the owner has switched that email off, so skip it quietly. sendEmail also takes three optional extras when a module needs them: headers (custom RFC 5322 headers - set your own Message-ID and a reply weeks later can be matched back to what prompted it), from ({name?, address}, for a module that answers on behalf of more than one address; leave it out and the site's own From is used) and transport (a Brevo key or SMTP details for that one send, for a module holding its own sending account). All three are optional, all three are logged the same as anything else, and whichever service is sending still has to be willing to send as the address you name. In return you get the owner's copy edits, the on/off switch, the merge-tag chips, preview, test-send and the site's email wrapper design for free - do not build a template table of your own. requiredTags names merge tags an edit may not drop (a link the email is useless without); rawTags names values your own code assembles as markup (an item table) so core does not escape them - never list a tag whose value comes straight from a form. | | smsTemplates | {groupLabel, import, export} | Optional. The same seam one channel over: registers this module's text messages with core's message registry, which whichever SMS-provider module is installed puts an editor on (with Twilio, that's Settings → Twilio → Templates). import/export name a module file exporting an SmsTemplateDef[] (lib/sms/registry.ts): {key, label, body, mergeTags, requiredTags?, transactional}. No subject and no wrapper - a text is one plain-text body. Every key must be prefixed with your module's own name (shop.order-shipped), same rule and same reason as the emails. Collected by scripts/generate-module-sms-templates.mjs into the gitignored lib/modules/sms-templates.ts. Send with core's sendSmsTemplate(to, key, vars), which never throws and answers {sent:false, reason} when there is no provider, no number or the owner has switched that message off - check isSmsAvailable() before offering a text as a choice anywhere in your UI. Keep defaults inside 160 characters: texts are charged per segment, and anything outside the plain-text set drops that to 70. | | requiresModules | Array<{name: string, minVersion: string}> | Optional. Other modules that must already be installed and active, at or above minVersion, before this one can be installed. The install route rejects the install with a clear message if a dependency is missing or too old, and the update route re-checks the incoming release's dependencies the same way, so raising a minVersion in a new release refuses to land on a site whose dependency is still behind. Uninstalling a module that another active module still depends on is blocked the same way, in reverse. A dependency counts as satisfied whenever it will be pinned into the registry the pending build reads - so a dependency mid-update, or one waiting on a manual redeploy, is judged at the version it is moving to, and only a disabled or failed one reads as absent. | | cronJobs | Array<{path: string, schedule: string}> | Optional. Scheduled jobs this module needs. path must be under /api/m/<your-module-name>/... (it's dispatched through the same generic module router as any other module API route, and a path outside your own module is refused). schedule is a five-field cron expression, UTC. Core runs these itself, off one Vercel cron entry - see Module cron jobs below for what that means for how precisely your schedule is honoured. | | cspOrigins | Record<'script'\|'style'\|'img'\|'font'\|'connect'\|'frame'\|'media'\|'form-action', string[]> | Optional. External origins your module's front-end genuinely needs, per Content-Security-Policy fetch directive. Every installed module's entries are collected by scripts/generate-module-csp.mjs into the gitignored lib/modules/csp-origins.ts and unioned into the site's policy in proxy.ts - so a third-party SDK that draws its own iframes (a payment provider's card fields, a map) loads with nothing for the site owner to configure, and stops being trusted the moment your module is uninstalled. Origins only: https:// scheme, host, optional port, and at most one leading *. label - https://web.example.com, https://*.example.com, https://pay.example.com:8443. No paths, no data:, no *, and no policy keywords - 'unsafe-eval' is rejected as an origin rather than passed through, and frame-ancestors and base-uri cannot be touched from a manifest at all. form-action can be, since 0.5.1239: 3D Secure POSTs its challenge form to the card network's authentication server from an iframe the payment SDK creates inside the site's own document, so the site's own form-action governs it - and at 'self' the shopper gets a blank modal instead of their bank's confirmation step. It and frame-src are the only directives that accept a bare * (0.5.1241 and 0.5.1242 - one 3D Secure step needs both, since the challenge is POSTed into an iframe pointing at the same host), because the challenge host is chosen by the shopper's bank and cannot be enumerated - an allowlist there fails silently for customers who happen to bank elsewhere. Ask for * only when you genuinely cannot know the origin in advance; it is a real trade the site owner makes by installing you. Declare only what you actually load: this widens a security policy for every page of somebody else's site. If your service lives on a subdomain of the owner's own site rather than a fixed vendor host, it is not knowable at authoring time - that is what CSP_EXTRA_ORIGINS is for. | | cacheBypassCookies | string[] | Optional. Cookie names whose presence means "this request is personal, never let a shared cache hold the answer". Collected by scripts/generate-module-cache-cookies.mjs into the gitignored lib/modules/cache-cookies.ts and honoured by the site owner's page cache (Settings → General → Speed). Core's own cactus_session and cactus_member_session are always honoured and never need declaring. Declare a cookie only if your module renders genuinely different HTML for a visitor carrying it. A cookie that changes nothing server-rendered - a dismissed banner, a chosen tab, an id your client-side code reads back through your own API - should not be listed: every name here narrows how much of the owner's site can be cached at all. The shop is the worked example of not declaring one: its basket cookie is only ever read inside its own cart API, the basket is fetched client-side and never server-rendered into a page, so listing it would have cost every returning shopper their caching for the thirty days the cookie lives while preventing no leak whatsoever. Cookie names only, no values, no wildcards. | | autoPlaceBlocks | Array<{ type, layoutType, position? }> | Optional. Blocks core should drop onto matching layouts when your module is first installed. For marker blocks only - a block that draws nothing and exists to say "run this module on every page using this layout". type is one of your own puckBlocks types; layoutType is the layout to place it on (header, footer, or any registered type); position is 'end' (default) or 'start'. Placement runs from whichever install path gets there first - markModulesDeploySucceeded() in lib/deploy/reconcile.ts, or seedPendingModuleLayouts() on a request path when that reconcile ran on a build without your code - and both sit behind the same Module.layoutsSeededAt stamp. First install only, and deliberately so: an owner who deletes the block, or moves it somewhere they prefer, must never find it back after an update - that is core editing their site behind their back. It is also never added twice: presence is judged across the whole layout document, zones included. Both the draft and the published copy are written, since an invisible block is not something an owner can notice and publish, but a layout that has never been published stays unpublished. Do not use this for anything a visitor can see. See lib/layout/auto-place-blocks.ts. |

Permission key convention

Use _own / _any suffixes for operations that can be scoped: forum.posts.delete_own (delete your own posts) vs forum.posts.delete_any (delete anyone's posts). This is a convention, not enforced by the framework - but it makes role configuration predictable.

The module database model

This is the most important section. Get it wrong and migrations become painful.

Rules

  1. Every table your module creates must start with tablePrefix.
    • Correct: forum_threads, forum_posts
    • Wrong: threads, forum_thread
  2. Migrations are plain SQL files, not Prisma migrations.
  3. The module migration runner applies them during Vercel's build step, in lexicographic filename order. They are never applied at runtime.
  4. Already-applied migrations are tracked in ModuleMigration (a core table): (moduleName, migrationName, checksum, sqlChecksum). The runner skips any migration already recorded there, and uses the two checksums to notice when a file it is skipping is no longer the file that ran - see Structuring per-version migrations.
  5. Modules query their own tables directly - raw SQL or a lightweight query layer - not through the core Prisma client.

Migration file structure

modules/forum/
├── migrations/
│   ├── 001_create_tables.sql
│   ├── 002_add_pinned_column.sql
│   └── 003_add_indexes.sql
└── ...

Files are applied in lexicographic (alphabetical) order. Use numeric prefixes to control order: 001_, 002_, etc.

Example migration file

migrations/001_create_tables.sql:

CREATE TABLE forum_threads (
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
  title TEXT NOT NULL,
  slug TEXT NOT NULL UNIQUE,
  body TEXT NOT NULL,
  author_id TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  pinned BOOLEAN NOT NULL DEFAULT FALSE
);

CREATE TABLE forum_posts (
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
  thread_id TEXT NOT NULL REFERENCES forum_threads(id) ON DELETE CASCADE,
  body TEXT NOT NULL,
  author_id TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX forum_threads_author ON forum_threads(author_id);
CREATE INDEX forum_posts_thread ON forum_posts(thread_id);

Example query (module code)

Since the core Prisma client doesn't know about forum_threads, modules use a raw database connection:

// modules/forum/lib/db.ts
import { Pool } from 'pg'

const pool = new Pool({ connectionString: process.env.DATABASE_URL })

export async function getThreads(limit = 20, offset = 0) {
  const { rows } = await pool.query(
    'SELECT * FROM forum_threads ORDER BY created_at DESC LIMIT $1 OFFSET $2',
    [limit, offset]
  )
  return rows
}

Or, for more ergonomic queries, use any lightweight ORM that doesn't depend on a generated Prisma schema.

Nav entries

Each navEntry in the manifest becomes a link in the admin sidebar when the module is active:

{
  "label": "Forum",
  "path": "/forum",
  "icon": "💬",
  "permission": "forum.threads.read"
}
  • path is relative to the admin root (the internal /_cactus_admin prefix).
  • If permission is set, the nav entry is only shown to users with that permission (or admins).
  • Nav entries from disabled modules are hidden immediately when the module is disabled.
  • Without navGroupLabel, your entries render as plain links directly under Dashboard, no section heading - not bucketed with other modules, not at the bottom of the sidebar. With navGroupLabel, your entries get their own named, collapsible section instead, rendered right after Content - use navGroupOrder to control ordering relative to other modules' labelled sections.

Linking between admin pages

Build every admin link inside your module from the real admin path, never the internal /cactus-admin/ prefix. Each site sets its own admin path during setup (the default is /cactus-admin/, but it might be /cacti/ or anything else), and proxy.ts rewrites /<adminPath>/* to the internal /cactus-admin/* while blocking direct access to the internal prefix. A hardcoded /cactus-admin/... link therefore bypasses the real path, hits a blocked route, and renders a blank page.

  • Client components: read the path with useAdminPath() from @cactus/components/admin/AdminPathContext, then build links as `/${adminPath}/m/<module-name>/...`. The context is provided by the admin shell, so the hook works on any module admin page with no prop drilling.
  • Server components: read the request header instead - const adminPath = (await headers()).get('x-cactus-admin-path') ?? ''.
  • Exceptions: API routes (/api/m/<module-name>/...) are not under the admin path and stay as-is, and nav-entry paths in the manifest are already prefixed with the real path by core, so they need no change.

Naming your admin screens

Export metadata (or generateMetadata) from each of your admin pages, exactly as you would from any Next page:

export const metadata = { title: 'Bills — Purchase Orders — Admin' }

Core reads it. Every module admin screen is rendered through one core route (app/cactus-admin/m/[module]/[[...path]]/page.tsx), and Next only ever reads metadata from the route file, so core's generateMetadata resolves your page and hands your title back: your own generateMetadata first, then your static metadata. Requires the core release that ships this (the first after 0.5.1568). Before that, an admin page's metadata export was loaded and silently discarded - if your module has one that never seemed to work, it works now, so check what it says.

  • Put the section name first. The whole point is a browser's history and tab strip: "Bills — Purchase Orders" is findable, "Purchase Orders" thirty times over is not. Core appends the site name.
  • Name the record on a detail screen. generateMetadata gets the same params your page does, so an order screen can be titled by its order number.
  • A client component cannot export metadata. Put it on a layout.tsx in that segment instead - a small server component that returns children and nothing else.
  • A tab you publish into core's Inbox is named from your manifest's label, not from the extension-point id, so ?tab=contact-form reads Inbox: Contact form — Admin. Write the label you want to see.
  • Say nothing and core still names the screen, from the sidebar link it sits under: /m/shop/orders/<id> becomes "Trading" because that is what the /m/shop/orders nav entry is called. Better than nothing, worse than a real title.

Permissions in module code

To check permissions from your module's server components or API routes:

import { getSessionFromCookie } from '@cactus/lib/auth/session'
import { hasPermission } from '@cactus/lib/permissions/check'

const user = await getSessionFromCookie()
if (!user || !await hasPermission(user, 'forum.threads.write')) {
  // Redirect or return 403
}

Disabled module permissions remain visible in the Roles matrix but are visually marked "module inactive". This means re-enabling a module doesn't silently wipe existing role assignments - admins who had forum.threads.write before the module was disabled still have it when it's re-enabled.

Raising admin notifications

Your module can raise notifications in the admin bell (the same bell core uses for deferred deployments). Import the generic helpers from core:

import { upsertAlert, clearAlert } from '@cactus/lib/notifications/alerts'
  • upsertAlert({ type, dedupeKey, title, link }) - raises (or re-surfaces) a single notification keyed by dedupeKey. If one with that key already exists and the title (or link) changed, it re-surfaces as unread; if the title is unchanged it's a no-op, so you never nag an admin about a notice they've already read. type is a NotificationType (modules typically use 'message'); link is an admin-relative path (e.g. /m/<module-name>/inbox) rendered as `/${adminPath}${link}`.
  • clearAlert(dedupeKey) - deletes the notification(s) with that key. Call this when the condition clears.

Rolling-count pattern (recommended for inbox-style modules). Keep one notification in step with a count rather than raising one-per-event. The contact-form module does this in lib/notify.ts:

import { upsertAlert, clearAlert } from '@cactus/lib/notifications/alerts'
import { countUnreadSubmissions } from './db'

export async function syncMessagesNotification() {
  const n = await countUnreadSubmissions()
  if (n > 0) {
    await upsertAlert({
      type: 'message',
      dedupeKey: 'contact-form:messages',
      title: `${n} unread message${n === 1 ? '' : 's'}`,
      link: '/m/contact-form/inbox?tab=unread',
    })
  } else {
    await clearAlert('contact-form:messages')
  }
}

Call it (fire-and-forget, .catch(...)) after every mutation that changes the count - on create, on status change, on delete - so the badge stays honest and clears itself at zero. Wrap the call so a notification failure never breaks your endpoint. Use a dedupeKey namespaced to your module (e.g. <module-name>:<concern>) to avoid colliding with core or other modules.

The install/update/disable lifecycle (from author's perspective)

Install

  1. Site admin enters your GitHub URL in Admin → Modules → Install a module.
  2. Cactus fetches cactus.module.json from your repo.
  3. Validates: manifest schema, tablePrefix uniqueness, required env vars, and requiresModules (every declared dependency must already be installed, enabled, and at or above minVersion - install is rejected with a clear message otherwise). "At minVersion" reads the version the pending deployment carries, not the last confirmed one: a dependency already going out at a new tag, or bundled into this very install by the "also update the others" tick, satisfies the requirement rather than reading as missing.
  4. Finds your latest tagged release and its commit SHA.
  5. Commits your repo as a git submodule at modules/<name> via the GitHub API.
  6. Vercel builds → generate-module-router.mjs wires admin pages and API routes, then run-module-migrations.mjs applies 001_create_tables.sql, etc.
  7. Module row flips to active (via webhook or lazy polling).

Update

  1. Commit changes to your module, add a migration if the schema changed.
  2. Tag and release: git tag v1.1.0 && git push --tags, create a GitHub Release.
  3. The Cactus admin sees "Update available" with your release notes.
  4. When they click Update, Cactus re-reads your cactus.module.json and checks the incoming release's requiresCoreVersion and requiresModules against the site. An update needing a newer core, or a newer version of a module the site hasn't updated yet, is refused with a message naming what to update first - it would otherwise break the site's next build on an import that doesn't exist there yet. (If your manifest can't be fetched at that moment, the check is skipped rather than blocking the update.) A requiresCoreVersion the site can satisfy by taking a waiting core release is offered as one deployment instead: the core files and modules.json are pushed in the same commit, so the build carrying the new core is the build carrying your module.
  5. Vercel builds → migration runner applies only new .sql files (already-applied ones are recorded in ModuleMigration and skipped).

Disable

Disabling a module is a database flag flip - no redeploy, no data loss. The module's tables remain intact. Nav entries disappear immediately. Permissions remain in the Roles matrix but are marked inactive.

Uninstall

  1. Admin clicks Uninstall on the installed module.
  2. A modal offers two options:
    • Remove code only - removes the submodule git entry and the Module DB row. Database tables are preserved.
    • Remove code and data - same as above, plus drops every table listed in teardown. Only available if the module declares teardown in its manifest.
  3. On confirmation, Cactus commits the submodule removal to the main repo, Vercel rebuilds, and the admin sees live deploy status in the notification bell.
  4. Uninstall is blocked if another active module's requiresModules still points at this one - remove the dependent module first.

Structuring per-version migrations

For the update flow to apply only new migrations correctly, follow this convention:

  • Each version that changes the schema adds a new .sql file. Never modify a migration file after it has been in a release. The runner records applied files by NAME, so a file that has already run never runs again however much it changes - which means an edit reaches installs provisioned afterwards and nobody else, silently, for ever. A live shop lost an order to exactly this on 8 September 2026: a released migration was edited to add a column, the site never re-ran it, and the next customer was charged for an order the checkout could not write.

  • Two things now enforce that, because the rule on its own was not enough:

    • Before release. npm test in core runs scripts/check-frozen-migrations.test.ts, which compares every module migration on disk against the same file in that module's newest release tag. Any difference fails, on the commit that makes it. node scripts/check-frozen-migrations.mjs runs the same check on its own and prints the git checkout line that puts the file back.
    • After release. The migration runner compares each applied file's recorded checksum against the file in the build. A mismatch is warned about loudly in the deploy log and raises an alert in the admin notification bell, so an install that has already been bitten says so instead of waiting to be found out.
  • 001_initial.sql is the one exception, and is edited in place on purpose: it only ever runs on a fresh install, so a change there can surprise nobody. When a later migration adds a column, add it to 001_initial.sql too, so a fresh install and an updated one land in the same place.

  • The fix for something missing from a released migration is always a new numbered file, never an edit. Make it idempotent (ADD COLUMN IF NOT EXISTS) so the overlap with a fresh install is harmless.

  • Then say so in the manifest, or the alert never goes away. A drifted file's checksum can never match again, so writing the catch-up fixes the database but leaves the warning standing on every deploy for ever - and an alarm that always fires is one nobody reads on the day it is right. migrationCatchups maps the edited file to the file that made it good:

    "migrationCatchups": {
      "043_returnable": "047_order_item_return_note_catchup",
      "004_supplier_catalogues": ["005_supplier_discount", "006_catalogue_column_map"],
      "041_spam": "none"
    }

    A list where it took more than one file, and the sentinel "none" where the edit changed no SQL at all. It is judged per install: the warning only clears once the named catch-up is itself recorded as applied on that site, and until then the alert names what the site is waiting for rather than printing two hashes at somebody. This is a statement about history - nothing else reads it, and it never changes what the module does.

  • A comment-only edit is not drift, and is now recognised as such without being declared. Alongside the raw checksum the ledger records a second hash of the same file with its commentary stripped - line comments, block comments and whitespace collapsed, string and dollar-quoted literals left exactly as they are, since a -- inside one is data. Two files that normalise the same do the identical thing to a database, so a note reworded a year later is re-recorded silently instead of raising anything. Rows written before that hash existed carry none, and are back-filled on the next deploy where the file still matches byte for byte; a file that had already drifted by then is the one case that needs "none" spelling out. The stripping itself is scripts/normalise-sql.mjs, kept apart from the runner so it can be tested without a database - scripts/normalise-sql.test.ts.

  • Use a naming scheme that sorts in the order migrations should run: 001_, 002_, 003_, etc.

  • A migration file should be idempotent where practical (CREATE TABLE IF NOT EXISTS, ALTER TABLE IF NOT EXISTS, etc.) to survive edge cases.

The build gate: never tag a module nobody has built

A module repo on its own cannot tell you whether its code builds. tsc and eslint there see a folder of files with no core around them, so the whole class of defects that only a real Next build catches - chiefly a React hook imported into a file with no 'use client', which makes it a server component and fails the bundler - sails straight past both. Tag such a version and the first machine ever to build it is a site owner's, mid-update. That happened on 2026-08-30 and it cost a live site its ability to update at all.

So every module repo carries a build gate. Add it with, from a core checkout:

node scripts/install-module-ci.mjs your-module-name

That writes .github/workflows/build-gate.yml - three lines calling the module organisation's reusable workflow. Commit and push it in the module repo. From then on, every push to main, every pull request and every v* tag:

  1. checks out core at its newest release,
  2. drops your module in at the commit under test (not the last released tag - the candidate),
  3. adds anything your manifest lists in requiresModules, at the versions core pins,
  4. starts a throwaway Postgres and runs the migrations, yours included,
  5. runs the build exactly as a deploy would.

Red gate means that commit would break every install that took it. Do not create the GitHub release until the tag's run is green - the tag existing is harmless, a release is what sites offer their owners.

To reproduce a failure against a specific core, pin it in your caller workflow:

jobs:
  gate:
    uses: cactus-foundation-modules/.github/.github/workflows/module-build-gate.yml@main
    with:
      core-ref: v0.5.1400

The gate needs no secrets: core and the module organisation are public repos.

Publishing to the module directory

Modules hosted in the cactus-foundation-modules GitHub organisation appear automatically in the Admin - Modules - Available directory. To list your module:

  1. Create a public repo under cactus-foundation-modules/your-module-name.
  2. Push your code there.
  3. Tag a release: git tag v1.0.0 && git push --tags.
  4. Create a GitHub Release for the tag. The release body becomes the "Update notes" shown in the admin.

Modules hosted elsewhere can still be installed if the admin knows the repo URL - the directory only shows cactus-foundation-modules repos as a curated shortlist. See the next section.

Writing a module for your own site

A module does not have to live in the module organisation, and does not have to be public. A site-specific module - something only one business would ever want - belongs in that owner's own GitHub account, where it never appears in anyone else's directory. The mechanics:

  1. Host it in your own account. Any https://github.com/you/your-module repo works, public or private. The manifest, migrations, release tagging and versioning rules are all exactly as described on this page - nothing about authoring changes.
  2. Publish a GitHub release. The install reads the manifest at the release tag, so an untagged repo cannot be installed. Pre-releases install through the Beta channel.
  3. For a private repo, grant the site's GitHub App access to it. The same App the site already uses to commit its module registry can fetch private module code at deploy time - on GitHub: Settings → Applications → the app → Configure, add the module repository. No new secret is involved. If you would rather not use the App (or the site is configured with a personal access token instead), set the MODULE_CLONE_TOKEN environment variable to a fine-grained PAT with read-only Contents access on the module repo; GITHUB_API_TOKEN is also tried as a last resort.
  4. Install it from Admin → Modules → "Add a custom module". Paste the repo URL, pick a channel, press Install. The same checks run as for a directory install - manifest validation, requiresCoreVersion, requiresModules, table-prefix uniqueness - and a private repo the site could not fetch at deploy time is refused up front rather than breaking the next deploy.

Once installed, a custom module behaves exactly like a directory one: it shows in the Installed list (top of the Modules page), gets update badges when you publish a new release, updates with one click, and uninstalls the same way.

Worth saying plainly to anyone pasting a URL: a module runs inside the site's own runtime with full database access. Install code you trust.

Module Puck blocks

A module can register Puck blocks that appear in both the page builder and the layout builder. Declare them in cactus.module.json:

"puckBlocks": [
  {
    "type": "MyWidget",
    "import": "./components/puck/MyWidgetBlock",
    "component": "myWidgetPuckComponent",
    "rscComponent": "myWidgetPuckRscComponent"
  }
]
Field Required Description
type yes The Puck component name (PascalCase). Must be unique across all installed modules.
import yes Module-relative path to the file exporting the component objects (no .ts / .tsx extension).
component yes Named export for the editor (synchronous) version of the block config object.
rscComponent no Named export for the RSC render version. If omitted, component is used for both.

scripts/generate-module-puck.mjs runs on every npm run build and npm run dev. It rewrites lib/puck/module-components.ts with the correct import statements. The resulting moduleComponents/moduleRscComponents records are spread into puckConfig, layoutPuckConfig, and their RSC variants so your block appears under the Modules category in the block picker. lib/puck/module-components.ts is gitignored and never committed to the core repo - it mirrors lib/modules/router.ts in this respect.

Block design rules

  • All settings live in the block's Puck fields. Do not create a separate settings page for per-form configuration - every block instance on the site can carry different settings.
  • Keep security-sensitive settings server-authoritative. For example, a contact form block's notification email and CAPTCHA toggle should be stored in the page's builderData, never sent by the browser. The submit handler must re-derive the config by looking up the saved builderData using the page/layout slug and block id.
  • Use puck?.id in the RSC render function to get the block's unique Puck identifier (available as props.puck.id in all render functions).
  • Gate settings behind real config status with resolveFields. If your block depends on integrations that may not be configured (e.g. email delivery, a third-party API), add an async resolveFields function to your editor component object. It receives (data, { fields }) and returns the fields to display. When the required integration is absent, return a single custom field that renders a warning banner instead of the normal controls - this prevents editors from configuring features that can't work. Use fetch('/api/auth/config') to check email and Turnstile status; cache the promise at module scope with a short TTL (60 s) so the function doesn't refetch on every panel keystroke.
  • Settings tabs sort your fields for you. The block settings panel splits long field lists into Block (content and behaviour), Appearance (fonts, colours, borders, animation) and Layout (padding, margins, widths, alignment, visibility) tabs, by field name (lib/puck/fields/fieldTabs.ts). Names like padding*, gap, maxWidth, align go to Layout; fontSize, *Color, bg*, border*, *Radius go to Appearance; anything unrecognised stays under Block. Arrays and objects always stay under Block. Panels with fewer than six fields, or fields that all land on one tab, show as one plain list. To override a name, tag the field ({ type: 'select', tab: 'appearance', ... }) or give the component a fieldTabs map (fieldTabs: { cardGap: 'layout' }); the map wins. Editors you build with <Puck> get the tabs by passing overrides={{ fields: tabbedFieldsOverride }} (from @/lib/puck/tabs/TabbedFieldsOverride); this needs requiresCoreVersion set to the core release that introduced it.

Module layout types

A module with its own public "listing" and "single item" pages (Directory's category/entry pages, Gazette's post listing/post pages, Boards' board/thread pages) can let site owners design those pages through the core Puck-based Appearance → Layouts editor, the same mechanism used for Header/Footer/Page Layout/404/Status Page - instead of a fully hardcoded page. Declare the group and its sub-types in cactus.module.json:

"layoutTypes": {
  "groupLabel": "My Module",
  "types": [
    { "key": "myModuleCategory", "label": "Category", "starterImport": "./lib/starterLayouts", "starterExport": "myModuleCategoryStarters" },
    { "key": "myModuleEntry", "label": "Entry", "starterImport": "./lib/starterLayouts", "starterExport": "myModuleEntryStarters" }
  ]
}
Field Required Description
groupLabel yes Label for the top-level tab shown on the admin Layouts list/wizard (e.g. "Directory"). Still used when host names a module that is not in the build.
host no Another module's name. Puts your layout types under that module's tab instead of opening one of your own - see below.
types[].key yes camelCase layout type string, convention <moduleName><Kind> (e.g. directoryCategory). Becomes the value stored in Layout.type.
types[].label yes Sub-tab label (e.g. "Category").
types[].starterImport/starterExport no Module-relative path (no extension) and named export of a () => Array<{id, name, description, data}> function providing starter templates for this type. Omit if you don't want to ship any starters.
types[].editorPreview no { "className"?: string, "maxWidth"?: number }. Only for a block-internal layout type - one your own surface stamps into a container of its own rather than rendering as a whole page. Describes that container so the standalone editor and the preview page can reproduce it. See below.
types[].pageSettingsImport/pageSettingsExport no Module-relative path (no extension) and named export of a { fields, defaultProps, before } object giving this type page settings - the root-level fields the layout editor shows with nothing selected. See below.

Page settings: pageSettingsImport / pageSettingsExport

Some settings belong to the sheet rather than to any block on it. A PDF's paper size, its margins and the scale it prints at are the worked example: no block draws them, and putting them on one block would mean an owner who deleted that block lost the page.

Declare an export shaped like Puck's own root config and core hands it to both the editor and the published render:

{ "key": "shopInvoice", "label": "Invoice document",
  "pageSettingsImport": "./lib/doc-page-settings", "pageSettingsExport": "shopDocPageSettings" }
export const shopDocPageSettings = {
  fields: { pageSize: { type: 'select', label: 'Paper', options: [...] }, /* … */ },
  defaultProps: { pageSize: 'a4' },
  before: ShopDocPageStyle,   // optional
}
  • fields and defaultProps are ordinary Puck root config. The values land in the layout's builderData.root.props, where your own server code can read them back - docPageSetupFromLayout() in lib/documents/page-settings does exactly that on the way into the PDF renderer.
  • before is an optional component handed the root props and rendered ahead of the layout's blocks. It is where a document turns its page settings into an @page rule; it wraps nothing, so a type with page settings and one without produce the same tree around the blocks themselves.
  • A type that declares none keeps root.fields undefined, which is what the layout editor reads to hide the root panel entirely - so a Product Card gains nothing it has no use for.

It has to be client-safe. The generated lib/puck/module-layout-roots.ts is imported by the editor config and the published RSC config, so anything reachable from this export that touches next/headers, Prisma or any other server-only API poisons the editor bundle. Keep it to plain data, plain functions and components that only ever emit markup.

Defaults are the compatibility contract, as everywhere else: make them exactly what the layout did before the settings existed, or every published layout of the type changes the day you ship them.

Printing a document: lib/documents

A module that prints paperwork - an invoice, a quote, a purchase order - does not write its own PDF machinery. Core carries it, in lib/documents:

Import What it gives you
lib/documents/page-settings documentPageSettings (paper, margins, orientation, scale - point your type's pageSettingsExport at it rather than writing your own), documentFooterPageSettings, docPageSetupFromLayout(), DocumentFooterRegion and the @page emitter. Client-safe.
lib/documents/pdf renderDocumentPdf({ path, pageSetup, footerCss, label }), printPath(), documentPdfFilename() and DocumentPdfUnavailableError. Server only.
lib/documents/footer renderDocumentRunningFooter(ctx) - the shared repeating footer, as a React tree. Server only, and heavier than the printer: it reads layouts and every module's RSC block map, so keep it out of your PDF route and on the document page.
lib/documents/context injectDocumentContext(data, ctx, partTypes) - clones the saved layout and attaches your document to the blocks that read it, once, by reference.

The shape of a document module is then:

  1. A layout type of your own for the document itself, with pageSettingsExport pointing at core's documentPageSettings, and part-blocks that read _ctx.
  2. A public page that renders that layout, strips the site chrome, and - when print=1 - renders renderDocumentRunningFooter(ctx) inside <DocumentFooterRegion>.
  3. A route that calls renderDocumentPdf({ path: printPath(yourPage, token) }).

Two things that will bite otherwise:

  • The running footer is documentFooter, and it is core's. Do not declare a footer layout type of your own. Add "documentFooter" to the layoutTypes of any of your blocks that belong on one (small print, a page number, a rule) and they turn up in its picker beside every other module's. One footer, designed once, on everything the site prints - which is rather the point, and the reason the shop's own copy of it was retired rather than kept alongside.

    Starter templates for it go in a top-level layoutStarters in your manifest, not in layoutTypes.types:

    "layoutStarters": [
      { "layoutType": "documentFooter", "import": "./lib/starterLayouts", "export": "documentFooterStarters" }
    ]

    They are appended to whatever the type already offers, so several modules can each put their own footer on the menu, and they are never seeded on install - publishing a footer for somebody would silently redesign every document their site prints.

  • The print URL must not be world-readable. printPath() puts a token in the query string because the headless browser fetches your page over HTTP and carries no session. Sign it, check it, and keep the page out of the search index.

Purchase Orders is the shortest worked example of the whole shape: a purchaseOrderDocument layout type of its own with thirteen part-blocks, a public page at /purchase-order/<number>, and lib/print-token.ts signing an HMAC over the order number and an expiry thirty minutes out.

A second document on the same module is the same shape again, with two things worth copying. Purchase Orders' returns note is a purchaseReturnDocument layout type with eight part-blocks of its own - but its Document style and Divider blocks are the order's, declared on both layout types in the manifest rather than written twice, and its blocks render the same CSS classes. A business that has designed one of its documents has designed both, and there is one stylesheet to keep right rather than two. Its public page is nested at /purchase-order/returns/<number> rather than claiming a second publicBasePath, because a module gets exactly one - two segments deep, so it can never collide with the order's own single-segment page. And its token is signed under its own subject string (purchase-return: rather than purchase-order:), so a token minted for one document cannot open the other. That page also opens for a signed-in user holding the module's own read permission, so the link keeps working after the token behind it has aged out - which is what lets one page serve both the printer and a human. How long a token should live is a judgement about the document: an invoice is a customer's own record and its token never expires, a purchase order is what you pay your suppliers and its token is measured in minutes.

Hosting your types under another module's tab: host

An add-on whose pages are part of another module's surface should not open a second top-level tab for two entries. quote-for-shop is the case in point: a quote document is a Shop document, and a "Quotes" tab sitting next to "Shop" made the owner hunt in two places for one shop's paperwork. Name the module you belong to and your types become sub-tabs on its tab:

"layoutTypes": {
  "groupLabel": "Quotes",
  "host": "shop",
  "types": [ { "key": "quoteDocument", "label": "Quote document" } ]
}

Only the tab moves. Each type still belongs to the module that declared it, and that is the module which has to be installed for it to appear, to be creatable, to be seeded with starters, or to be cleaned up when the module goes: a site with the Shop but no Quotes add-on sees the Shop tab without the two quote sub-tabs. Give your sub-tab labels enough to stand on ("Quote document", not "Document") - they sit beside the host's own labels with nothing to say whose they are.

host is advisory. If it names a module that declares no layout types in this build, the generator warns and you keep your own tab, so a shop-less install is never left with types it cannot reach.

Set requiresModules for the host as well - host describes where a tab is drawn, not what your module depends on.

Block-internal layout types and editorPreview

Most layout types render as a page: whatever the author builds is the whole output. A block-internal type is different - the layout is a flat list of parts, and one of your surfaces stamps it into a container of its own, once per item. Shop's Product Card is the worked example: renderCards wraps each stamped card in <a class="shop-card">, and that anchor is what arranges the parts - image on top, image beside the text, or image filling the card, chosen by a class on the parts and applied by the container with :has() and child selectors.

The catch is that the container only exists on your surface. Open the same layout in Appearance → Layouts and there is nothing around the parts, so every template of the type renders identically and any part positioned against the container escapes across the canvas. editorPreview closes that gap: name the class (and optionally a width, so a card previews at card size rather than full-bleed) and the editor and preview page draw the same container your surface does.

{ "key": "shopProductCard", "label": "Product Card", "editorPreview": { "className": "shop-card", "maxWidth": 340 } }

Two rules make this work, and both matter if your CSS uses child combinators (.shop-card > :not(.shop-card-img)):

  • Emit the container's stylesheet from the parts themselves when there's no context to say a surface already did. The core knows the class name, never the CSS.
  • Give that stylesheet an href and a precedence if the part can appear more than once on a page. A <style dangerouslySetInnerHTML> is emitted once per instance, which is invisible on a product page and expensive on a grid: product-3d-views' card overlay mounts once per card and was stamping a 9 KB stylesheet each time, about 380 KB of byte-identical CSS inside the HTML of a 43-product category page. React 19 de-duplicates and hoists <style href="…" precedence="default">{css}</style> to <head>, keeping the first and dropping the rest, and merges same-precedence sheets into a single tag. Two catches: the CSS must be children, not dangerouslySetInnerHTML (the hoisting rules only apply to children - React does not HTML-escape style children, so > and ~ combinators come through intact), and the href is the identity, so it has to be one shared constant rather than a string written out at each call site. Keep it in a tiny shared component and import that everywhere, the way P3dChrome.tsx does.
  • Declare each part inline: true and attach puck.dragRef to its own root element. Otherwise the editor wraps every part in a <div> of its own, which lands between the container and the part: the part stops being a grid item, ~ stops seeing siblings, and the wrapper (which Puck gives position: relative) becomes the containing block an absolutely-positioned part stretches to. Your live page has no such wrapper, so this is also what keeps editor and storefront markup identical. Note Puck also sets position: relative inline on that element, which outranks a stylesheet rule - a part that must position against the container needs !important.

Then tag any puckBlocks[] entries this layout type should offer, in addition to the usual page-builder palette:

"puckBlocks": [
  { "type": "MyModuleCategoryHeader", "import": "./components/puck/MyModuleCategoryHeaderBlock", "component": "myModuleCategoryHeaderPuckComponent", "rscComponent": "myModuleCategoryHeaderPuckRscComponent", "layoutTypes": ["myModuleCategory"] }
]

scripts/generate-module-layout-types.mjs runs on every npm run build/npm run dev, alongside the Puck block generator. It writes two gitignored files: lib/layout/module-layout-types.ts (pure data - the groups list and a flat type→group lookup, consumed by the admin Layouts list/picker, LayoutPuckEditor.tsx, app/layout-preview/[id]/page.tsx, and DisplayConditionsPanel.tsx) and lib/setup/module-starter-layouts.ts (the starter-template loader functions, merged into core's catalogue by lib/layout/starter-templates.ts). Your tagged blocks additionally appear in moduleComponentsByLayoutType/moduleRscComponentsByLayoutType (from lib/puck/module-components.ts), scoped per layout type - core's getModuleLayoutPuckConfig(type)/getModuleLayoutPuckRscConfig(type) build an editor config from those blocks plus the shared layout/typography/actions/media/content categories, and one block out of the site category: SiteLogo. The rest of that category stays out (a menu, a login button, a theme toggle mean nothing on a module content page), but a module layout is not always a page - the invoice and the quote are paperwork, and paperwork has a letterhead. That letterhead is core's SiteLogo rather than a picture field inside each document module, so every document on the site draws the same logo the same way. Its published render (SiteLogoWithSiteBranding in lib/puck/config.rsc.tsx) fetches the site's logo itself through a React cache()d read, because module layouts never pass through resolveTemplateData - the step that fills logoUrl/siteName in on the header, the footer and the status pages. lib/puck/module-layout-site-logo.test.tsx covers both halves.

Keep your starter file pure data. It must not import anything server-only (no prisma, no next/headers): the admin's new-layout picker imports the merged catalogue directly in the browser to draw a preview of each template. Plain object literals and local helpers, nothing more - which is what every existing module's lib/starterLayouts.ts already is.

You get previews for free. The picker draws each template's structure from its own data - containers (Grid, Split, Section, Group) give it the shape, and your blocks become boxes inside it. components/admin/LayoutPreview.tsx names no module and needs no registration; a block type it has not heard of falls back to a plain box, and the structure around it still tells the owner whether they are picking the sidebar variant or the full-width one. Nothing to ship, nothing to maintain.

Rendering your public page with the published layout. Your page fetches its data as normal, keeps its existing notFound()/visibility gating, and only then tries the layout:

const layout = await resolveThemeLayout('myModuleCategory', { moduleName: 'my-module', slug: category.slug })
if (layout?.builderData) {
  const data = injectCategoryContext(layout.builderData as PuckData, { categorySlug: category.slug /* ...whatever your blocks need */ })
  return <Render config={getModuleLayoutPuckRscConfig('myModuleCategory') as any} data={data as any} />
}
// else: fall through to your existing hardcoded JSX, unchanged

injectCategoryContext is your own small helper (lib/inject-category-context.ts), one per layout type - clone the stored Puck Data, walk content/zones recursively, and for any block whose type is in a fixed Set of your context-consuming block names, Object.assign your live request values into that block's props. This mirrors Shop's pre-existing injectProductContext (modules/shop/lib/inject-product-context.ts) - copy that file's shape rather than inventing a new one. Each context-consuming block should be a self-contained "anchor" (permissions: { delete: false, duplicate: false } for anything core to the page, e.g. the entry list or a reply list) that re-fetches its own data from the injected props - blocks don't share a data-fetch, each is independently sufficient.

Every layout type you declare is seeded a default, and never at site setup. One template per type is created as a published <template-id>-live Layout when your module is installed, not when the site is set up. The seeding runs in markModulesDeploySucceeded() (lib/deploy/reconcile.ts) as your install deploy lands, guarded by Module.layoutsSeededAt so a later update redeploy cannot re-mint a layout the owner has deleted.

Which template, per type (planModuleSeedTemplates() in lib/setup/starterLayouts.ts):

  • one or more templates of that type marked publishByDefault: true - those are seeded. Mark the one that best matches the page your module renders without a layout;
  • none marked - the first template of that type is seeded. Forgetting the flag no longer means the owner opens Layouts to an empty tab;
  • at least one marked publishByDefault: false and none marked true - nothing is seeded for that type. Say it out loud like that when you genuinely want the owner to choose, and expect to justify it: a designable page type with no design is a puzzle, not a feature.

This was not always so. publishByDefault used to be a precondition, so a module that never set it got nothing - and because the install stamped layoutsSeededAt regardless, no later release of that module could put it right. Gazette shipped six starters, flagged none, and every site that ever installed it had an empty Gazette tab that no update could fill. Hence both halves of the fix: the flag is a preference rather than a precondition, and a stamped module owning not one layout across every type it declares is treated as never seeded and gets another go (planModuleSeeds()). A module with layouts for some of its types and none for others is left alone - deleting one type's layout to fall back to your hardcoded page is a decision, and re-minting it behind the owner's back would change a live site unasked.

That reconcile is not guaranteed to run on the deploy it is reconciling: the Vercel webhook or status poll is served by whichever instance is live at the time, which is routinely the previous build - one with no copy of your module's code, and therefore no copy of your starter templates. Seeding there would write nothing and stamp layoutsSeededAt regardless, turning "seed once" into "never"; that is exactly how a live Shop lost its product, index, checkout and confirmation layouts and 404ed every product URL. So the seed is also gated on isModuleInBuild(name), which checks the generated modulesInBuild list rather than asking whether any templates were found (an empty list cannot tell "this module has no starters" from "this module's code is not here yet"). A module absent from the build is skipped unstamped, so the deploy that does carry its code seeds it - and the stamp now goes on only when the seed actually created something (or when your module declares no layout types at all), rather than on the mere fact of having tried. Anything that still slipped through - including installs stamped before any of these gates existed - is recovered by seedPendingModuleLayouts() on the next request, which picks up both a NULL layoutsSeededAt and a stamped module with no layouts whatsoever. None of this needs anything from you: ship your starters and the platform sorts out when they can honestly be seeded. Seeding module templates at setup used to be core's behaviour and was a bug: a fresh site has no modules, so it stamped every module's default layouts into sites that did not have the module. Correspondingly, if your module is uninstalled - or was never installed on a given site - its layouts are pruned by pruneUninstalledModuleLayouts (kept across a code_only uninstall, which preserves your tables and migration ledger for a reinstall; removed by code_and_data).

Module settings tabs

A module can add a tab to the core admin's Settings (/config) page instead of shipping its own standalone settings screen. Declare it in cactus.module.json:

"settingsTabs": [
  {
    "id": "my-module",
    "label": "My Module",
    "permission": "my-module.manage",
    "import": "./components/SettingsTab",
    "component": "MyModuleSettingsTab"
  }
]
Field Required Description
id yes Unique tab id, used as the ?tab= query value and as the key modules key off (e.g. in OAuth-callback redirects back to /config?tab=<id>).
label yes Tab label shown in the settings tab bar.
permission no Permission key required to see the tab. Omit for a tab visible to anyone who can reach /config.
host no Name of another module's settings slot to render this panel inside, instead of as a top-level Settings tab. See Hosted settings panels below. When set, the panel does not appear as its own tab and is not a /config?tab= destination.
parent no "general" files the tab as a page of Settings → General (after Speed, Media, Admin Menu, GDPR & Legal, Schedules and Backup, before Danger zone) rather than giving it a top-level tab. Use it for a module whose settings are a handful of site-wide switches rather than a whole area - Google Tag, SEO and Gazette do. It is still /config?tab=<id>, so links do not change. A core that predates parent ignores it and shows a top-level tab, so it needs no requiresCoreVersion bump.
import yes Module-relative path to the file exporting the tab component (no .ts / .tsx extension).
component yes Named export for the tab's React component. Must be a client component ('use client') - it's rendered directly inside the client-side settings page. It receives no props and manages its own state/fetching, exactly like a standalone admin page would.

scripts/generate-module-settings-tabs.mjs runs on every npm run build and npm run dev. It rewrites lib/modules/settings-tabs.ts (gitignored, same pattern as lib/puck/module-components.ts) with a moduleSettingsTabComponents record keyed by id. The core settings page (app/cactus-admin/config/page.tsx, a server component) reads every active module's settingsTabs live from Module.manifest, permission-filters them the same way navEntries are filtered for the sidebar, and passes the visible list into the client page - which looks up each visible tab's component from the generated record.

Your tab's content renders with no extra chrome - no page title, no wrapping card - since it sits directly under the shared tab bar. Add your own heading only if the tab label alone wouldn't be enough context.

The Save button, sub-tabs and width

The Settings page keeps its title, tab bar and Save button in one sticky bar, so Save is in reach however far down a long tab you have scrolled. Core's own tabs drive that button directly. Yours owns its state and its own save call, so it puts its button in the bar with SettingsHeaderActions instead of at the foot of the form:

import { SettingsHeaderActions, SettingsHeaderStatus } from '@/components/admin/SettingsHeaderActions'

<SettingsHeaderActions>
  <SettingsHeaderStatus message={saved ? 'Saved' : null} error={error} />
  <button type="button" className="btn btn-primary" disabled={saving} onClick={save}>
    {saving ? 'Saving…' : 'Save changes'}
  </button>
</SettingsHeaderActions>
  • The content is portalled into the bar but stays part of your React tree, so state, handlers and context all keep working. Use type="button" with an onClick: a submit button that lands outside its <form> submits nothing. Keep onSubmit on the form if you want Enter to save.
  • Outside the Settings page there is no bar and the content renders where you wrote it, so a panel that is reused elsewhere still has a working button.
  • A panel you keep mounted but hidden (to hold a half-typed form while another sub-tab is showing) must stop rendering its actions while hidden, or every hidden panel's Save button will sit in the bar at once. Render SettingsHeaderActions only for the active panel.
  • Anything that saves itself on the spot (a tick-box, a dropdown, a list row) needs no button. A row-level edit inside a list may keep its own small Save.
  • Name the button for what it saves. Where a tab has two different saves (settings, and credentials that go to the server's environment) give each its own label.
  • Save is always the right-most button in the bar. Anything else that acts on the whole page (Reset to defaults, Cancel, a status line) goes inside the same SettingsHeaderActions, written before the Save button, so it sits to its left. The core page draws its own Save after every module's actions for the same reason.
  • One Save per page. If a page holds both ordinary settings and a credential, have the one Save write both (save the credential first, and stop if it fails) rather than putting a second Save beside it.

A tab with more than a screen of settings is split into sub-tabs with TabStrip (components/admin/TabStrip), one job per sub-tab, and keeps the open one in the URL with useTabParam (components/admin/useTabParam). Use a key other than sub if your panel is hosted inside Shop (Shop owns sub and pane): the Google Shopping and Abandoned baskets panels use view.

Use the shared TabStrip for those sub-tabs and nothing else is needed for the Settings sidebar. When the owner switches on Show Settings tabs in a sidebar, the first TabStrip your tab draws reports its items to the sidebar (components/admin/SettingsNav), which lists them as a tree under your tab, and clicking one there runs the same onClick your strip does. A strip drawn inside one of your sub-tabs (sub-sub-tabs) is picked up too and hangs under the active item of the strip above it, so the tree goes as deep as your tabs do. Strips inside a panel that is hidden or display: none are left out. With Also hide the sub-tabs on every settings page on as well (only offered while the sidebar is on, and switched off with it), every one of those strips is hidden on a wide screen and the sidebar is the way round. So draw your sub-tab strip before anything else in the tab, and do not use a TabStrip for something that is not sub-tabs. If a strip carries a control on its right (trailing), that control stays on the page when the strip is hidden.

Do not cap your tab's width - there is nothing in the page to stop a form stretching, so a maxWidth on your outer wrapper just leaves a strip of empty page to the right. Lay content out in cards instead, using the classes in app/globals.css:

Class What it does
settings-cols / settings-masonry Wrap your sections. When the children are .cards the sections stack full width, one under another (stretched - a card never shrinks to the width of what is in it). When the children are flat fields or switches with no cards, they flow into two columns.
settings-col A stack of cards; stacks full width like the above.
card-title The heading of a section card. Use it rather than a hand-styled <h3>, so every section reads the same.
settings-check A tick box and its words on one line: <label className="settings-check"><input type="checkbox" /> Words</label>. Like any field it takes half the row, so two tick boxes sit side by side, or a tick box sits beside a text box. Never leave one tick box alone on a full-width line.
InfoTip Not a class: import { InfoTip } from '@/components/admin/InfoTip' and put <InfoTip>the long explanation</InfoTip> inside the label. It draws a small (i) that shows the text in the house tooltip on hover or focus. Keep a field's visible hint to one short line and put the rest in here. Safe inside a tick-box label (it does not tick the box). Needs core 0.5.1763.
field / settings-field A direct child of a .card with this class takes half the card's row, so fields sit two across inside a section, never more than two, however wide the screen. A field on its own stays half width rather than stretching. Everything else in the card (titles, hints, tables) takes the whole row; buttons keep their own width.
field--wide Opt a field out of the half row. Textareas are wide automatically.
field--beside-input Put on a tick-box field that sits next to a text box or select, so the tick box lines up with the input rather than its label. Stacks normally on a phone.
settings-fields Put on a wrapper div around a group of fields to make that group its own two-column grid. A div holding only tick boxes lays them out two across as well.
settings-group A switch and the settings it reveals, kept together in half the row (for example "Ask for a phone number" with "Make it required" under it). Several groups pair up two across.
settings-card-row Wrap two small cards (an env-var card and a button, two short lists) to put them side by side on a wide screen. Use it only for cards that are short and related.

Sections stack; fields inside a section are two across. Before adding a new card, ask whether its one or two fields belong in a neighbouring card instead: a run of one-field cards is a lot of scrolling and white space. Merge them into one card with a title that covers both, or put two small cards in a settings-card-row. Give every section a card-title, and every input a <label> above it; inputs, selects, textareas and labels inside a settings card are normalised to the house size, so do not size them by hand. Spacing inside a section comes from the grid alone: top and bottom margins on a section's direct children are ignored, so do not reach for marginBottom to space things out.

Hosted settings panels

Sometimes a settings panel belongs inside another module's settings, not as its own top-level tab. A GoCardless payment provider, for example, should sit on the Shop's Payments tab next to Stripe and PayPal, not float as a separate "Instant Bank Pay" tab. Set host on the settingsTabs entry to the name of the slot to render into:

"settingsTabs": [
  {
    "id": "gocardless-ibp",
    "label": "Instant Bank Pay",
    "permission": "shop.manage",
    "host": "shop.payments",
    "import": "./components/admin/SettingsTab",
    "component": "GoCardlessSettingsTab"
  }
]

The core settings page (app/cactus-admin/config/page.tsx) renders every hosted panel server-side, permission-filtered exactly like top-level tabs, and groups them by host into two shapes (both in lib/modules/hosted-settings.ts), threaded through ConfigPageClient to every module settings tab as optional props:

  • hostedSettingsSlots: Record<slotName, ReactNode> - the slot's panels merged into one node. For a host that just drops them into a section of its own UI and needs nothing else about them.
  • hostedSettingsPanels: Record<slotName, { id, label, node }[]> - the same panels kept separate, each still carrying the id and label from its manifest entry. For a host that has to say something about each panel first, which in practice means giving each one a tab, since a tab strip needs the labels up front and cannot recover them from a merged node.

Both are always passed, so a host can change its mind without a core change. The panel component itself is unchanged either way - it still receives no props of its own and manages its own fetching, saving and permission checks.

To expose a slot from your own settings tab (i.e. become a host), accept whichever prop suits and drop the nodes in where they belong:

// Merged: everything contributed to the slot, under a heading the host wrote.
export function SomeSettingsTab({ hostedSettingsSlots }: ModuleSettingsTabProps = {}) {
  return <>{hostedSettingsSlots?.['some.slot']}</>
}

// Labelled: one tab per contributed panel. Shop's Payments tab does this - each
// payment method a module adds gets a button of its own beside the built-in ones.
export function ShopSettingsTab({ hostedSettingsPanels }: ModuleSettingsTabProps = {}) {
  const panels = hostedSettingsPanels?.['shop.payments'] ?? []
  // ...one tab per panel, labelled panel.label, rendering panel.node when active
}

A hosted panel saves through its own module's API, so a host showing one should stand its own Save button down while it is on screen - otherwise the button is there to be pressed and does nothing to what is under it.

The slot name (shop.payments) is a free string owned by the hosting module - core never knows or checks it, so a contributing module and a hosting module simply have to agree on the name (document your slot names in the host module's README). A hosted panel that targets a slot no host renders is silently invisible, so keep the contributing module's requiresModules / requiresCoreVersion in step with the version that introduced the slot.

Module extension points

settingsTabs and puckBlocks are extension points core publishes. A few core pages publish generic points of their own that any module can contribute to:

Point Where it renders Notes
core.inbox-tabs Inbox (core screen) A tab on the shared Inbox, for a module that receives messages (contact form, live chat). Entries take label and order on top of the usual fields. The panel is a server component and receives the page's query string as searchParams, so it can filter and page itself. Core hides the Inbox link entirely when nothing publishes into this point.
core.conversation-provider Nowhere on its own - feeds core's All inbox tab, and any module presenting every channel at once Data contract, not a component. The same conversations core.inbox-tabs renders as a panel, published as plain data instead, so somebody else can merge, count, sort and search them. Export a ConversationProvider (lib/conversations/types.ts): label, channel (email/chat/form/phone/sms), capabilities, list(), thread(id), and optionally send(), markRead() and byIdentity(). ConversationSummary.href must be admin-root relative with no leading slash (inbox?tab=contact-form&id=42) - only the rendering page knows this site's admin path, and anything containing :// is dropped. Two optional fields on a summary carry what a channel knows and core does not: destinationId - where the sender addressed it, an id published through core.message-destinations, handed back untouched - and sourceLabel, what on the site it came from in your own words ("Get in touch"), which is a name for a person to read rather than an id. Both are absent on a channel with nothing to say, which is most of them. Core renders a read-only All tab whenever two or more providers resolve for the viewing user, so publishing one pays for itself with no other module installed. Set serverOnly: true on the entry: a provider lives in your lib/, and without the flag its whole graph (an IMAP client, a telephony SDK) is reachable from a public page.
core.message-destinations Nowhere on its own - read by any module that collects something from a stranger Data contract, not a component. Answers "where should this be delivered?" for a module that has no business knowing what an inbox is. Export a MessageDestinationProvider (lib/conversations/types.ts): label (what you call the set - "Inboxes", "Teams") and list() returning { id, label, detail? }. The id is yours and opaque to everybody else: a collecting module offers your list, stores whatever id it was given and hands it straight back on ConversationSummary.destinationId without ever asking what it points at. Consumers read the list through GET /api/admin/message-destinations (gated on pages.write or layouts.manage, since a page-builder field is what asks) and must work perfectly well when it comes back empty - a site with no module publishing any is the ordinary case. A destination that has since been deleted is simply not recognised, so check an id before acting on it rather than trusting it. Set serverOnly: true - a provider reads its own module's tables.
core.reply-suggestions Nowhere on its own - read by any module with a reply box on it Data contract, not a component. Answers "is there anything on this site that could draft a reply?" Export a ReplySuggester (lib/conversations/types.ts): label, isConfigured() and suggest({ messages, subject, authorName, count }) returning plain-text drafts. isConfigured() is cheap by contract - a reply box asks it while it is being drawn, so it reads a settings row and never an API. A refusal worth reading is thrown as a ReplySuggestionError (lib/conversations/reply-suggestion-error.ts, its own file so both ends can import it without pulling in the resolver), which carries the status the host route answers with; anything else that escapes is logged and reported as a shrug. Consumers call canSuggestReplies() on the server to decide whether to draw the button at all and suggestReplies({ messages, subject, authorName }) from their own route, then render core's ReplySuggestions client component (components/admin/ReplySuggestions.tsx) under their writing box - it owns the button, the three drafts and the tick/cross, and the host keeps its own box through `onPreview(text
core.dialler Nowhere on its own - read by any module that wants to ring somebody Data contract, not a component. The other half of the telephony seam: core.conversation-provider publishes what was said, this places a call. Export a Dialler (lib/dialler/types.ts): label, isConfigured(), numbers() (the site's own numbers, for a caller-ID menu) and dial({ to, from, callMeAt }). Two-leg: callMeAt is required, because the site rings whoever pressed the button first and connects the two once they answer - nothing dials a customer and hopes somebody is holding the phone. A refusal comes back as { ok: false, reason } rather than thrown, since "that number is not on the account" is a sentence for the person at the keyboard. Consumers ask firstDialler(user) (cheap, database only) to decide whether to offer the button and callerNumbers(user) (reaches the telephony API) on the screen that opens. Set serverOnly: true - a dialler carries account credentials.
gazette.admin-tabs Gazette admin, a tab after Templates at /m/gazette/<id> A whole-page tab on the Gazette admin, published by a module that wants to work with the site's posts (Social Hub's Social posts is the example). Entries take label and order on top of the usual fields, and the entry id becomes the last segment of the URL. The component is rendered by Gazette's [tab] page and receives posts: every post the viewer may see (an Editor sees all, anyone else only their own, capped at 1000), as { id, title, excerpt, url, status, date, canShare } where url is the full public address and canShare is false for anything not live or members-only. Declare that shape in your own module rather than importing it - your module has to build on a site with no Gazette. Gazette names no module; with nothing publishing here it shows exactly the tabs it always did.
core.media-tabs Media page A tab beside Library and Video, for a media-adjacent tool (the watermark remover). Same label/order fields.
core.roles-page Users → Roles For modules needing per-user role assignment UI outside the core Role/permission model (Gazette's Contributor/Author/Editor tiers were the original example).
core.admin-dashboard-widgets Admin dashboard Summary widgets (e.g. Boards' thread/post counts).
core.admin-background-services Every admin screen (invisible) Client-side background work that should keep running while somebody has the admin area open - mail collection, campaign ticks, and anything else that must not unmount when they leave the screen that used to host it. Renders nothing visible; permission-filtered from the manifest like dashboard widgets. Unified Inbox registers AdminMailPulse here.
core.mobile-bar-items The Mobile Bar block, on the public site One cell of core's phone-style bottom bar. A client component taking { label, iconSize, showLabels } (ModuleMobileBarItemProps in lib/puck/mobileBar.ts) and rendering a single <button> - core draws no chrome round it. Use core's own class names and nothing else: cmb-item on the button, cmb-icon on a wrapper round your SVG, cmb-label on the caption, cmb-badge on a count pinned to the icon. The bar's stylesheet dresses them, so a contributed cell is exactly the size, colour and shape of the Home and Menu cells beside it with nothing coordinated between you. Give the entry a label - it is what the site owner picks from in the editor. Order is the owner's, not yours: every cell is a row in the block's own list, so your basket can sit third and the chat fifth. Your cell is drawn only where the module is installed; on a site without it the cell is skipped and the others spread out, so one header design serves both. Shop contributes shop-basket (live count, opens the basket up from the bottom), live-chat contributes live-chat-open. Anything else pinned to the bottom of the viewport should clear the bar by reading var(--cactus-bottom-bar-offset, 0px), which core publishes at the breakpoints the bar is shown at and nowhere else.
core.menu-entity-provider Menu editor Data contract, not a component - lets a module's content appear as menu link targets.
core.media-private-storage Nowhere visible - keeps a folder out of the media page's storage check Data contract, not a component: a plain readonly string[] of the folder names your module keeps deliberately row-less files in, relative to the provider's own media prefix ('unified-inbox', not 'media/unified-inbox/' - core builds the full prefix itself, and it differs between providers). Only for files that are meant to stay OUT of the library: a picture that is part of an email rather than attached to it, a file dropped onto a reply nobody has sent yet, anything a person with media permission has no business seeing in the picker. (The Unified Inbox declares this folder for exactly those; its actual attachments go INTO the library, filed under the correspondent, and use the two points below instead.) Core then files them under Kept outside the library on purpose, never as leftovers and never as files an admin is invited to give a library entry to - which is the whole point, since adopting one would put it in the picker. Asked BEFORE the usage providers, so an attachment nobody has opened yet is as safe as one that is referenced. Contract in lib/media/private-storage.ts. Declare it in its own tiny file with no imports: everything contributed to an extension point is statically imported by the generated public map. If your module's files DO belong in the library (a receipt filed under a Bookkeeping folder, say), you want a core.media-usage-providers entry instead and nothing here.
core.media-reference-rewriters Nowhere visible - runs on media edits Data contract, not a component. A module that stores media urls in its own tables registers one async rewriter ({oldUrl,newUrl,oldKey,newKey}) => Promise<void>; core runs every registered one as the final step of a media reference rewrite, so when the library moves a blob (optimise to WebP, rename, resize, crop, replace) the module's url columns follow it instead of 404ing. Shop uses it for product images (shp_product_media), shop-variations for image swatches (svr_option_values). Contract in lib/media/reference-rewriters.ts. A rewriter may throw: it runs before the old blob is deleted, so a failure aborts with the old url still serving. A rewriter is only half the job: it cannot touch a url your module stores AFTER the move from a screen opened BEFORE it, which is how three live product photographs got deleted as spare. Call repointToCurrentMediaUrls (lib/media/former-addresses.ts) wherever you write a media url into your own table.
core.media-reference-detachers Nowhere visible - runs on media deletion Data contract, not a component. The rewriter's counterpart for the one operation with no new address to point at: deletion. A module registers one async detacher ({id,url,key}) => Promise<void> and core runs every registered one as the last step before the row and the blob go, so a reference is made ABSENT rather than left naming a file that has stopped existing. Shop drops the gallery row (repacking position and promoting a new primary), nulls thumb_url and the category/collection/product image and social-image columns; shop-variations nulls the three swatch columns but keeps the option value, which is still orderable. Contract in lib/media/reference-detachers.ts, core's own half (SiteConfig icons, InfoPage.ogImageId, Member.avatarMediaId) in lib/media/detach.ts. Wired into all three delete routes and deleteFolderCascade; only called when the usage scan found a reference, so an Unused-tile sweep pays nothing. A detacher may throw, and core runs every detacher BEFORE deleting anything, so a throw aborts the delete with the item still serving - and core's own writes go last, so a module blowing up cannot cost the site its logo for a delete that then did not happen. Must be idempotent ("make this absent" is the same answer twice) and must NOT delete what a customer or an audit depends on - a purchased download, a proof-of-delivery signature, a builder document's block. Leave those attached and let the 409 do the talking. Its raw SQL is executed by NO standard gate: npm run test:media-detach runs it against a real throwaway Postgres.
core.media-usage-providers Nowhere visible - feeds the media library's Unused count Data contract, not a component. A module that references media from its own tables registers one async provider () => Promise<string[]> returning the raw reference strings those columns hold - media urls, storage keys or Media ids, in any mixture, and JSON columns can be returned whole as text. Core folds them into the same haystack it scans page and layout builder JSON with, so the item counts as in use. Register one whenever your module stores a media reference anywhere, or every image your module owns is counted as unused and offered up for bulk deletion. Contract in lib/media/usage-providers.ts. If a provider throws, core logs it and treats the entire library as in use rather than trusting a half-built index.
core.inventory-adjuster Nowhere visible - runs when something needs a stock count changed Data contract, not a component. A module that keeps stock counts registers one adjuster { label, adjust(adjustments) => Promise<outcomes> }; anything that needs a count moved (goods arriving on a purchase order, a stocktake, a hire going out) asks core for whatever is registered instead of importing the counting module - whose files are not in the build at all on an install without it. adjust is a batch: a delivery is a dozen lines, and a dozen round trips is a dozen chances for half of them to land. An adjustment is { productId, delta, reason, ref?, userId?, note? } and an outcome is { productId, ok, before, after, message? }, answered in the order asked. Contract in lib/inventory/adjusters.ts. Do not throw for an ordinary refusal - an unknown product, one that keeps no count, a move that would go negative - because the caller has already filed the paperwork that prompted it and cannot unfile it; return ok: false with a message a person can read. getInventoryAdjuster() returns one, not all: two modules both counting the same product would each apply the delta and a delivery of six would put twelve on the shelf. Shop registers shopInventoryAdjuster (lib/stock.ts), which also clears the low-stock reminder and tells the back-in-stock subscribers.
core.agent-content Nowhere visible - serves /llms.txt, /llms-full.txt and /<any-page>.md Data contract, not a component, and register it serverOnly: true. One object with any of llmsIndex(siteUrl), llmsFull(siteUrl) and markdown(path, siteUrl); return null from any of them for "nothing here", which 404s the address. path arrives with the .md already stripped and no leading slash, and the empty string is the home page. Core owns the three addresses and knows nothing about what belongs at them - no content types, no tables, no Markdown. It applies one gate of its own: a site with Hide from search engines ticked, or a status other than live, 404s all three whatever you return. Contract in lib/agent-content/providers.ts; ultimate-seo is the worked example. Two things to know before you write one. It is read from the FULL extension-point map, not the public one, which is why it must be serverOnly - the providers here read a whole catalogue out of the database and have no business being reachable from a page's bundle; the three routes that call them are route handlers with bundles of their own, so nothing leaks. Do not build the document on the request. A catalogue of twenty thousand products rendered to Markdown per fetch is a bill rather than a feature (measured: ~1.5s per hundred products, with their variations and attributes). Build them on a schedule into a table of your own and let the provider do one indexed read, the way ultimate-seo does.
core.well-known-files Nowhere visible - serves /.well-known/… Data contract, not a component. A module that needs a verification file served at a fixed path under /.well-known/ registers one async provider () => Promise<Record<string, string>>, keyed by the path after /.well-known/ and valued with the file's contents. Core owns the path and knows nothing else - no filenames, no providers, no contents - because the path is fixed by whoever is doing the checking and is out of reach of your own /api/m/<module>/… routes. Apple Pay is why it exists: Apple fetches /.well-known/apple-developer-merchantid-domain-association, follows no redirect to anywhere more convenient, and refuses its sheet on a domain that has not answered. Contract in lib/well-known/providers.ts. Served text/plain with nosniff, afterFiles so a real file in /public/.well-known still wins, and a provider that throws is skipped rather than 500ing the path. Return {} while the owner has pasted nothing in: an empty file with a 200 reads to a verifying party as a failed check, where a 404 reads as an absent one.
core.outbound-email-identity Nowhere visible - runs on every sendEmail that names a module Data contract, not a component. Answers "who should this module's automatic email go out as?". Export an OutboundEmailIdentityProvider (lib/email/identity.ts): one async identityFor(moduleName) returning { from, replyTo?, transport? } or null for "leave it to Settings → Emails". Core asks only when the payload carries moduleName and names no from of its own, so a caller that was specific is never overridden; the first provider with an answer wins, and one that throws is stepped over rather than allowed to lose the email. Unified Inbox registers one so a site can send its purchase orders as accounts@ and its order confirmations as orders@, with the replies coming home to those inboxes. Costs nothing on a site with no provider installed - the check returns before it reaches the database. Set serverOnly: true: a provider lives in your lib/ and will reach a mail transport. To make your own module's emails eligible, pass moduleName: '<your-module>' on your sendEmail calls - it already names the module in the email log.
core.outbound-email-record Nowhere visible - runs after every successful sendEmail that names a module Data contract, not a component. Offers you a copy of another module's automatic email, the moment it has gone: { moduleName, templateKey?, from, replyTo?, to, cc, subject, html, text, messageIdHeader?, providerMessageId?, sentAt, attachments } (RecordedOutboundEmail in lib/email/record.ts). Export an OutboundEmailRecorder with one async record(email). Unlike the identity point, every recorder installed is offered it - two modules wanting a copy is two filing cabinets, not a contradiction - and one that throws is logged and stepped over. Only mail carrying moduleName is offered, so core's own password resets and sign-in codes never are; a failed send is not offered at all; and attachments holds only what actually travelled, so a copy cannot claim a document the recipient never got. providerMessageId is the handle a reply usually comes back on, because most services stamp their own Message-ID over anything we set - store it if you want the answer to thread onto your copy. Unified Inbox registers one: a purchase order sent to a supplier is filed as a conversation in the inbox chosen for that module's copies, and the supplier's reply lands underneath it. That choice is its own setting - Keep a copy of these in, separate from the sending address in core.outbound-email-identity - so a site can file a module's mail without changing the address it goes out as, which is what a shop wants for its order emails. Costs nothing on a site with no recorder installed - the check returns before it reaches the database or copies a byte. Set serverOnly: true: a recorder lives in your lib/ and is handed attachment bytes.
admins.account-section Admin Account settings page Per-admin self-service sections, rendered above the Delete account card (e.g. Twilio's SMS login codes card). Omit permission for self-service features every admin should see.
members.account-section Member account overview page Per-member sections (e.g. Shop's latest-order card, Twilio's text-message sign-in codes). No permission filtering - members have no permission keys. A section named by a members.account-nav item's sectionId is treated differently: it is the tab's own content, so it is drawn only on the one-page account, in the tab's place in the running order. Everything else is a card on the overview in both shapes.
members.account-nav Member account tab bar A data contract, not a component: export an async function (member: {id, email, emailVerified}) => MemberAccountNavItem[], where an item is { key, label, href, badge?, sectionId? }. href is a full public path, because the pages belong to your module and live under your own routes (Shop contributes /shop/account/orders and /shop/account/addresses). badge is a small count pill - use it for things waiting on the member, not for a total. sectionId names one of your own members.account-section contributions and is what joins your tab to the one-page account: on a site with that switched on, core draws that section into the account page - after Account & Security, where your tab sits - and your tab scrolls to it instead of loading href. Leave sectionId off and your tab stays a page in both shapes, which is the right answer for anything too big or too stateful to unroll under somebody's passkeys. Either way the page at href carries on existing, because bookmarks, emailed links and people with JavaScript off all still land on it. Contract in lib/members/account-nav.ts. Return [] when your module has nothing to offer this member (switched off, gated, not applicable): a tab that leads to a "not available" notice is worse than no tab. A provider that throws is logged and dropped rather than taking the account area down, but do not rely on that - a member locked out of their own security settings by your bad day is not a good look.

Member pages your module owns. A tab from members.account-nav points at a page under your own routes, and that page is not inside app/(public)/cactus-account, so it never sees the member area's layout - left alone it renders bare, with no tabs and no way back to the account. Wrap it in core's shell instead:

// modules/<you>/app/public/<you>/account/things/page.tsx
import MemberAccountShell from '@/components/members/account/MemberAccountShell'
import { getMemberFromCookie } from '@/lib/members/session'
import { getMemberAreaPath } from '@/lib/members/paths'

export default async function ThingsPage() {
  const member = await getMemberFromCookie()
  // Your gate, not core's: only you know where to send them back to.
  if (!member) redirect(`/${getMemberAreaPath()}/login?redirect=/<you>/account/things`)

  return (
    <MemberAccountShell member={member} maxWidth={880}>
      {/* your page */}
    </MemberAccountShell>
  )
}

Pass member when you have already loaded it (you will have, for the gate) and the shell skips a second session lookup. notice takes a page-level banner rendered above the tab bar; maxWidth defaults to 720. The shell deliberately does not redirect - a signed-out visitor gets your children rendered bare, because core does not know which of your paths to send them back to afterwards.

A module can publish its own extension points too, for other modules to contribute to - most commonly a module extending the pages of a hard dependency it declares in requiresModules. Core never learns the point's name; it only runs the generic collection mechanism described below.

Publishing a point (in the host module, e.g. contact-form): pick a namespaced string id for the point (convention: <your-module-name>.<page-or-area>, e.g. contact-form.submission-detail), document what data your components receive, and read/render contributions from lib/modules/extension-points.ts in your own page - live permission-filtering happens in your page code, exactly like navEntries are filtered in layout.tsx:

// modules/contact-form/app/cactus-admin/contact-form/inbox/[id]/page.tsx
import { prisma } from '@/lib/db/prisma'
import { hasPermission } from '@/lib/permissions/check'
import { moduleExtensionPointComponents } from '@/lib/modules/extension-points'

// ...inside the page component, after loading `user`:
const activeModules = await prisma.module.findMany({
  where: { status: { in: ['active', 'update_available'] } },
  select: { manifest: true },
})
const visibleIds: string[] = []
for (const mod of activeModules) {
  const manifest = mod.manifest as { extensionPoints?: Array<{ point: string; id: string; permission?: string }> } | null
  for (const entry of manifest?.extensionPoints ?? []) {
    if (entry.point !== 'contact-form.submission-detail') continue
    if (!entry.permission || await hasPermission(user, entry.permission)) visibleIds.push(entry.id)
  }
}
const components = moduleExtensionPointComponents['contact-form.submission-detail'] ?? {}

// ...in the JSX, wherever the contributed content should render:
{visibleIds.map((id) => {
  const Extra = components[id]
  return Extra ? <Extra key={id} submissionId={id} /> : null
})}

The props your point passes (submissionId above) are your own contract - document them so contributors know what to expect. Contributed components can be plain (even async) server components if your page is a server component, or client components if you render them from a client page - match whatever your own page already is.

Contributing to a point (in the contributing module, e.g. contact-form-reply-catcher): declare an extensionPoints entry in cactus.module.json:

"extensionPoints": [
  {
    "point": "contact-form.submission-detail",
    "id": "my-module-contribution",
    "permission": "my-module.manage",
    "import": "./components/MyContribution",
    "component": "MyContribution"
  }
]
Field Required Description
point yes The extension point id, as documented by the module that publishes it.
id yes Unique id for this contribution within the point (a point can have contributions from more than one module).
permission no Permission key required for the contribution to render. Omit to always render when the point itself is reachable.
import yes Module-relative path to the file exporting the component (no .ts / .tsx extension).
component yes Named export for the contributed React component.
serverOnly no true withholds this entry from the client-visible extension-points.public.ts wherever its file sits. The generator's default rule is "public unless the file is under components/admin/", which is right for a React panel and wrong for a plain function: an entry pointing at a file in your lib/ is otherwise reachable from a public page's bundle, dragging its dependencies with it. Set it on every data-contract entry that is not genuinely safe in a browser graph.

A point may define extra fields of its own on top of these. The generator only ever reads point/id/import/component, and the host page reads the manifest out of the database itself, so anything else a point documents rides along for free with no generator change. shop.product-editor-sections uses this for label (the tab's name) and order (where it sits among the editor's own tabs) - see below. Always give such a field a sensible fallback in the host: a manifest written before the field existed simply will not have it.

scripts/generate-module-extension-points.mjs runs on every npm run build and npm run dev. It collects extensionPoints from every installed module's manifest, groups them by point, and writes the gitignored lib/modules/extension-points.ts (moduleExtensionPointComponents: Record<point, Record<id, Component>>) - same generated-file pattern as lib/puck/module-components.ts and lib/modules/settings-tabs.ts.

Points that aren't components

A contribution doesn't have to be a React component. component just names an exported binding, so a point can equally collect a plain function (contact-form.thread-messages, shop.cart-line-resolver) or an object of several exports (core.menu-entity-provider, shop.product-detail-parts). The host module declares the contract as a TypeScript type in its own lib/, and the contributing module imports that type and satisfies it - modules/shop/lib/line-meta.ts and modules/shop-variations/lib/line-resolver.ts are the shortest example of the pair.

core.menu-entity-provider: answer in batches

A provider for this point offers resolveEntity(kind, id) so core can turn a saved menu item into a live label and href. It may also offer resolveEntities(kind, ids), which answers a whole set of ids of one kind at once and returns a Map keyed by id, with anything it cannot resolve simply absent (exactly as resolveEntity returns null for it).

Offer it. A menu is resolved on every single page render, and core used to walk the tree asking about one item at a time, each waiting for the last. A header with seven links to a module's content therefore cost seven round trips, one after another, before the page could be drawn. On a real install that made a single 48-row table the most-read thing in the database.

Core now gathers every visible item's target up front and asks once per (module, kind) pair, then builds the tree without touching the database at all. A provider that offers only resolveEntity is still called per id, but concurrently rather than in sequence, so it is not left behind - it just keeps its query count where a batching provider drops to one.

The visibility rule is unchanged, and belongs to the provider either way: return publiclyVisible: false for something the admin table should still show but a public menu should not (a draft post, a members-only board).

One behaviour worth knowing: a provider that throws now loses only its own items from the menu. It no longer takes the whole navigation down with it.

A shop.cart-line-resolver may also return an optional control - a small, declarative per-line picker (a key, a label, the current value, and a list of options). Shop renders it generically as a <select> on each basket line and, when it changes, writes the choice back into that line's meta and re-validates - so the contributing module never ships a React component into shop's cart, only data. advanced-shipping-for-shop's delivery-tier picker (modules/advanced-shipping-for-shop/lib/line-resolver.ts) uses this: the same resolver call prices the chosen tier, snapshots the promised date onto the order line, and describes the picker the shopper changes.

Each option may additionally carry a numeric priceAdjust - the same figure already baked into its label. When present, a new-enough shop moves the line price and subtotal the instant the shopper picks the option, then reconciles with the server's re-validate; the server-calculated price remains the only one that is ever charged. The field is optional both ways: a resolver that omits it simply leaves the price to catch up when the round-trip returns, and an older shop ignores it.

renderAs picks the presentation: 'select' (the default dropdown), 'radios', or 'summary' - the chosen option confirmed in place as a ticked bar with every other option beneath it as a one-click chip. 'summary' asks more of the resolver, because shop will not take a label apart to find the date or the price inside it: every option must also carry a summary of { headline, secondary?, switchLabel?, priceLabel? }, which shop displays verbatim. Miss it off any one option and shop quietly falls back to the radio group rather than render half a card. advanced-shipping-for-shop's tierOptionSummary is the worked example - the same delivery service split into "Arrives by Thu 6 Aug", "Flat-pack", "Flat-pack by Friday" and "Free".

A per-line resolver can only speak about its own line, so there is a companion point for the thing only the whole basket knows: shop.cart-summary. A provider takes every cart line ({ product, quantity, meta } - the shopper's raw meta, so it applies its own defaults exactly as its line resolver does) and returns one short { id, text } note, or null when it has nothing to say. Shop shows the notes in the cart's sticky checkout bar beside the item count and never composes or interprets the wording. It runs after the lines resolve, so the request-scoped caches the prefetcher warmed are already full - a provider should be arithmetic over those, not a fresh round of queries. A provider that throws is skipped rather than allowed to fail the validate: these notes are chrome, and no shopper should lose their basket display over a decorative line. advanced-shipping-for-shop's summariseCartDelivery is the worked example - it walks the lines for the latest promised date and returns "everything by Fri 4 Sep".

A resolver runs once per basket line, so if it does its own database reads it will fan out across a whole cart. A resolver that reads the database should therefore also register the companion shop.cart-line-resolver-prefetch point (same id, a function taking the full ShpProduct[] for the cart). Shop calls every prefetcher once, up front, before it folds the lines, so the module can resolve the whole cart in one batched pass and stash the result in a request-scoped cache() store that the per-line resolver then reads. The prefetch is optional and additive: a resolver without one, or an older shop that predates the point, simply falls back to the resolver's own per-line lookups. advanced-shipping-for-shop (lib/delivery-cache.ts) and shop-variations (lib/addon-cache.ts) are the worked pair - batching the delivery-estimate and add-on lookups this way is what keeps a multi-line cart a handful of queries rather than a handful per line.

Lines resolve at the same time. Shop resolves every basket line concurrently, after every prefetcher has finished; on any one line the resolvers still run one after another. So a resolver must be safe to run beside itself for other lines: read your own request-scoped store, never write shared state per line, and never make a call that would trip a rate limit when a basket holds forty lines. The contract is written out above CartLineResolver in shop's lib/line-meta.ts.

A payment provider saying no. On shop.payment-providers, a failed confirmPayment only marks the order Payment failed when the result carries declined: true - the provider saying this order's own payment was refused. Any other failure (an empty payload, somebody else's intent, a payment still processing) leaves the order payable. A payment that is committed but still settling should return { success: true, pending: true }. And handleWebhook may return paidAmount: { minorUnits, currency } on a PAID result - what was actually taken - which shop checks against the order before trusting it in full, holding the order with a note on a mismatch. On a REFUNDED or PARTIALLY_REFUNDED result it may return refundedTotal (a decimal string: everything refunded on the payment so far, the shop's own refunds included); shop takes off what it recorded itself and records the rest as a refund made in the provider's dashboard. Providers that settle through drafts (settlesExistingOrder) are unaffected: a draft never had an order to mark.

Extra charges (settlesOrderCharges). Shop can raise a charge on an order after it was placed - a redelivery fee - and take payment for it separately from the order. A provider opts in with settlesOrderCharges: true, and in return: createIntent and confirmPayment are handed the CHARGE's id as orderId and the charge's total as amount, and must work off those alone (no order or checkout draft exists under that id); and any other path that settles a payment - a webhook, a return route - must check getOrderChargeForSettlement(id) from shop's lib/order-charges.ts first, and settle a charge with settleOrderChargePayment(chargeId, { method, providerReference }) (idempotent) after checking the amount and currency, instead of looking for an order. The order's own payment method and reference are never changed by a charge. A provider that does not claim it is simply not offered for charges.

Restating a snapshot when the facts change: shop.order-payment-state

A cart-line resolver writes its line snapshot once, at the moment the order is placed, and every surface afterwards - the thank-you page, the confirmation email, the account's order history, the admin - reads that prose back verbatim. That is exactly right for a personalisation ("Engraving: For Dad") and wrong for anything whose truth depends on the money having arrived. shop.order-payment-state is the seam for the second kind. Shop calls every provider with { order, items } at the two moments an order's payment state moves: straight after the order is created (the first point at which it knows which method it is on) and again inside fulfillPaidOrder before any email is composed. A provider returns { items?, note? } - replacement LineMetaFields for named order items, which shop merges into the stored line_meta by label so a module restates its own field and nothing else on the line moves, plus one optional sentence shop prints under the chosen method on the checkout's payment step. Returning null is the normal answer and costs nothing.

There is a third moment, and it is the one the shopper actually needs: the instant they pick the method, before an order exists at all. POST /api/m/shop/public/checkout/payment-note takes { lines, paymentMethod } and calls every provider with the same { order, items } shape plus preview: true (previewOrderPaymentNotes, lib/order-payment-state.ts). The payment method and the resolved line meta are real; every other figure on that order is a placeholder and the ids point at nothing, because at that point in the checkout the address may be half-typed and the totals are not settled. Only note is used - line restatements are dropped, since there is no line to restate - and nothing is written or created. A provider that reads only what it is handed can ignore the flag entirely; one that goes back to the database for its own order row, or writes anything of its own, must return null when it sees it. Without this the sentence arrived from the order-creating route instead, which cannot be called until the whole checkout is filled in and every compulsory box is ticked - so the shopper was told what bank transfer does to their delivery dates some way past the point they were deciding.

Two supporting details make it work. LineMeta carries an optional data bag - opaque, namespaced per module, never read by shop - so a resolver can stash the machine-readable version of its promise (a service key, a working-day count) rather than having to parse its own sentence back apart later. And a provider that throws is caught and skipped: the order is already written and the payment is the next thing to happen, so a module having a bad day must not cost the shopper their checkout.

advanced-shipping-for-shop's restateDeliveryForPayment is the worked example, and its first cut is the cautionary tale. It keys on order.paymentStatus and nothing else - never on a method id, and no longer on the provider's confirmMode: 'manual' either. Keying on confirmMode sounds right and reads as "the money has not moved yet", but it is really "the shopper has a job to do", and the two come apart: Instant Bank Pay is an 'auto' method whose payment is merely submitted at the checkout and collects a working day or two later, so orders on it sat unpaid promising dates counted from a starting gun that had not fired. The rule is now the plain one - while the order is unpaid every delivery line states a lead time instead of a date; the moment it is paid, each line is re-dated from order.paidAt using the service the shopper actually bought. confirmMode is still the right key for the checkout note, which genuinely is about a job the shopper has to go and do.

A provider may also return data beside fields on an item restatement: keys of the line's line_meta.data to replace, merged by top-level key the same way fields merge by label, so a module updates its own namespaced state and nobody else's. Shop never reads it. It exists so a module that re-dates a line can keep the new date as a date, not only as a sentence - restateDeliveryForPayment keeps the payment-day date as ashDelivery.paidTargetDate, beside the quoted targetDate rather than over it. A line is rewritten when either its wording or its data would change.

When a line is due: shop.order-line-due-date

The orders list shows Delivery due Tue 22 Sep in its Dispatch column: the soonest day anything on the order is due at the door. A parcel on its way answers that from its own booked delivery day, but an order nobody has dispatched yet has no parcel - its dates are promises another module made at the checkout and filed in line_meta.data, which shop never reads. So shop asks. A provider is (lines: OrderLineForDueDate[]) => Record<itemId, 'YYYY-MM-DD'> (sync or async), where each line is { itemId, orderId, lineMeta, paid } and paid says whether the order's money has landed - a promise counted from dispatch has no date before then. Leave out any line you promised nothing for. Contribute it with serverOnly: true; shop reads it from the server registry (resolveOrderLineDueDates, modules/shop/lib/order-line-due-date.ts).

Shop checks each answer is a real calendar day, keeps the sooner one where two providers answer the same line, and then decides per order (nextDueDate): a line with units still to go out is due on its promise; a parcel not yet delivered is due on its booked day, or on its lines' promises when none is booked; a delivered parcel is due nothing. Completed, cancelled and refunded orders are never asked about. Because a provider reads state it has been writing all along, the list is right for every order already placed the moment the provider is installed - nothing to backfill. A provider that throws is logged and skipped; a missing date is a shrug, a list that will not load is not. advanced-shipping-for-shop's advancedShippingLineDueDates is the worked example.

Where a product lives on the storefront: shop.product-storefront-link

The admin order screen links each line's name to the storefront page it was bought from. A product's own slug is not always that page: a variation is a catalogue-hidden child, and the page anybody looks at is its parent listing with the combination chosen. So shop asks. A provider is (productIds: string[]) => Record<productId, { slug, query }> (sync or async): slug is the page to open, query the query string to open it with (a=b&c=d, no leading ?) or null. Leave out any id you do not recognise; shop falls back to that product's own slug. Shop applies the site's product URL style itself, validates every answer, takes the first provider to answer an id, and logs and skips a provider that throws. Contribute it with serverOnly: true; shop reads it from the server registry (resolveProductStorefrontHrefs, modules/shop/lib/product-storefront-link.ts). shop-variations answers with the parent and the same query its sitemap and canonical tag publish.

Being told the money arrived: shop.order-paid

shop.order-payment-state is for restating what an order's lines say. shop.order-paid is for doing something about the money having landed - buying the goods in from a supplier, telling a warehouse - and the two are deliberately separate seams: one is asked a question and its answer is stored, the other is simply told.

An observer is (event: OrderPaidEvent) => Promise<void> | void, where the event is { orderId, orderNumber, paymentMethod, clearedManually }. Plain strings, and no shop types at all, which is the whole point of the shape: an observer typing its argument against ShpOrder would have to import @/modules/shop, and a module that does not declare shop in requiresModules cannot - that path does not exist at build time on an install without a shop. An orderId is enough to go and read whatever you need by raw SQL behind your own capability check.

Where it fires is the reason to trust it: the end of fulfillPaidOrder (modules/shop/lib/order-fulfillment.ts), which is gated on markOrderPaid() returning true. Every payment path on the platform funnels through it - card, PayPal, Square, GoCardless, a bank transfer the owner clears by hand, and a zero-total order that never touched a provider - so one observer covers all of them and no payment module needs to know you exist. It runs exactly once per order; a replayed webhook marks nothing paid a second time and so announces nothing a second time.

Three rules, and the last one is the one that bites. It is called last, after the stock, the downloads, the invoice and both emails, so nothing an observer does can cost the shopper their order. Every observer is try/caught and its failure logged, and the gather itself is caught by fulfilment, so a broken module cannot fail a payment webhook. And an observer must not lean on that catch: it is running inside a webhook whose timeout is somebody else's, so it should answer its own "am I switched on" question first and cheaply, catch its own failures, and never do work the site has not asked for.

purchase-orders' purchaseOrdersOrderPaidObserver is the worked example. Its first line reads a cached config flag that is off by default and returns; with it on it drafts one purchase order per supplier and sends nothing to anybody. It also shows the other half of the lesson: a hook is not a guarantee. A webhook can die, a module can be installed the day after an order was paid, a site can be running a shop older than the point. So the module pairs it with a nightly sweep that asks the same question of the last week's paid orders and picks up whatever the announcement missed. If what your observer does actually matters, write the sweep too.

Being told an unpaid order exists: shop.order-placed-unpaid

The other half of shop.order-paid. Card and provider orders announce order-paid seconds after they are placed, so that one point covers them. A bank transfer, cash, or a module's own confirmMode: 'manual' method can sit unpaid for a week, and the shopper is a real customer the whole time. shop.order-placed-unpaid (modules/shop/lib/order-placed-hooks.ts) is told once, when the checkout confirm route parks such an order as awaiting confirmation - a repeat call finds it no longer pending and announces nothing.

An observer is (event: { orderId, orderNumber, paymentMethod }) => Promise<void> | void: plain strings, no shop types, for the same reason as order-paid. It fires after the how-to-pay emails, every observer is try/caught, and the gather itself is caught, so nothing an observer does can cost the shopper their order. The same order announces order-paid later, when the money is cleared, so an observer listening to both must be safe to run twice. unified-inbox's unifiedInboxOrderPlacedUnpaidObserver is the worked example: it runs the same fill-in-the-blanks contact sync as its paid observer.

Keeping your own rows in step with a product: shop.product-saved

A module that mirrors part of a product into rows of its own has a standing problem: it hears about the copy it makes, and never about the original changing afterwards. shop.product-saved (modules/shop/lib/product-saved.ts) is the seam for that. A hook is (productId: string, changed: readonly string[]) => Promise<void> | void, where changed names only the fields that write actually carried - so the usual answer is to look at the array and return.

The important part is where it fires: at the end of updateProduct in shop's own lib/db, not in the admin route. A product is written from at least three places that never meet a route handler - the editor's PUT, the CSV upload, and the Google-Sheet Pull, the last two by the hundred in a loop - and hooking the route would have covered one of them. The registry read is memoised for the same reason, and skipped altogether when the build carries no module that declares the point, so a shop-only site pays nothing.

Two rules. The hook is awaited, so whatever it writes has landed before the caller reads the product back - but every hook is also try/caught and its failure logged rather than raised, because a listener falling over must never lose the owner's edit. And a hook should be cheap on the fields it does not care about: it runs on every save, of every product, including the middle of an import.

shop-variations' syncVariationsOnProductSaved is the worked example, and shows what the point is for. Each variation is a hidden child product row of its own, so shop rates a basket line from the child's tax class while the product page prices from the parent's. The children copied the class when they were created and nothing put them back in step, so a shop that set up VAT after generating its matrices quoted the same chair with tax on the page and without it in the basket. The listener gates on changed.includes('taxClassId') and runs one UPDATE that moves only the children that disagree.

Enhancing one field the host owns: shop.checkout-address-lookup

Sometimes the contribution is behaviour on top of a field shop already renders, not a new surface. shop.checkout-address-lookup collects a client component that shop mounts in place of the checkout's plain Address line 1 field - first registered provider wins, in manifest order. The contract (modules/shop/components/public/checkout-address-lookup.ts) is { value, onSelect, renderInput }, and renderInput is the point of it: the provider calls it to render shop's own labelled input, passing whatever extra input props it needs (ARIA combobox attributes, autoComplete: 'off'), so the field can never drift visually from its siblings and keeps working untouched when the provider does nothing. The provider layers its own dropdown, keyboard handling and fetches around that input, and calls onSelect with a complete { line1, line2, city, county, postcode } when the shopper picks a suggestion - shop applies it as a single state update. Two rules learned the hard way: the resolver lives server-side only (modules/shop/lib/checkout-address-lookup.ts reads the generated extension registry, which statically imports Prisma-touching module code - it must never reach a client bundle, which is also why the shipping block's RSC half sits in its own rscImport file), and a provider must degrade to nothing on any failure - a checkout that breaks because an address API is down is worse than no lookup at all. address-lookup-for-shop is the worked example.

A word of your own at the checkout: shop.checkout-contact-extras

Where the address lookup enhances a field shop owns, this one adds something beside them. It collects client components that shop mounts at the foot of the contact step, under the email box, in manifest order - every registered extra renders, because two modules asking two different questions is an ordinary checkout. The contract (modules/shop/components/public/checkout-contact-extras.ts) is deliberately thin: { customerEmail, preview }. Shop has no opinion about what the extra asks; it only decides where it goes.

Anything the extra needs to remember for the rest of the checkout goes into shop's own checkout state (updateCheckoutState), which is already shared between the blocks, already kept for the visit, and already what the order route reads - inventing a second store beside it is how two halves of one checkout end up disagreeing. The same rules as the lookup apply: the resolver is server-side only (modules/shop/lib/checkout-contact-extras.ts, which is why the contact block's RSC half sits in its own rscImport file), and an extra must degrade to nothing on any failure, because a checkout that breaks over a tickbox is worse than no tickbox.

abandoned-carts-for-shop is the worked example: its "Don't email me about offers and similar products." box lives here rather than in shop's own tickbox list, so a shop without the module installed carries none of it, and uninstalling takes the question away with the module. It draws nothing until there is an email address above it.

Taking a card on the shop's own page: shop.checkout-payment-fields

A payment provider registered on shop.payment-providers decides how money moves; this one decides where the card is typed. It collects a client component keyed by payment method id - the same string as the provider's own id, so square-payment-for-shop registers under SQUARE - and shop mounts it under that method's radio button, but only once the shopper has chosen it and its intent has come back. A shop with two card providers installed therefore loads exactly one SDK: the one being paid with.

The seam is deliberately incurious. Whatever the provider's createIntent puts in the new clientFields on its ShpPaymentIntent arrives at the component as config, verbatim; whatever the component's submit() returns is posted to /checkout/confirm as the payload and handed straight to that provider's own confirmPayment. Shop reads neither. The contract (modules/shop/components/public/checkout-payment-fields.ts) is { config, payer, onError, registerSubmit }: payer is the contact and address as the checkout has them, because a card SDK needs both for the bank's Strong Customer Authentication step; registerSubmit hands shop the function Place order calls, and withdrawing it (registerSubmit(null)) on unmount is what stops a shopper who has switched to bank transfer having their card reached for.

Four rules, three of them the same ones the address lookup learned:

  • The fields are drawn in the review step, not where they are built. Shop portals them into a slot above Place order, because a card is typed to pay and the thing that pays is the button - fields three blocks up the page get filled in before the shopper has seen the total, and skipped by anyone who scrolls straight down. Your component neither knows nor needs to: it stays in the payment block's React tree, so nothing remounts and nothing typed is lost. A layout with no review step draws them in the payment block instead. See modules/shop/components/public/checkout-payment-slot.ts.
  • The resolver is server-side only. modules/shop/lib/checkout-payment-fields.ts reads the generated extension registry, which statically imports Prisma-touching module code, so the payment block's RSC half sits in its own rscImport file (ShopCheckoutPayment.rsc.tsx) exactly as the shipping block's does.
  • clientFields goes over the wire to a public checkout. Put publishable things in it and nothing else: a publishable application key, the amount to authorise, which of your environments you are in. Never a secret, and never anything the client could act on unilaterally.
  • Nothing the client returns is evidence. submit() hands back a one-time card token, not an outcome. The server still asks the provider whether the money actually moved, and re-checks the amount and currency against the order while it is there.
  • Declare your SDK's origins in cspOrigins (see the manifest table above), or the site's Content-Security-Policy will block the card fields from ever drawing.
  • Write your own reassurance line. Shop prints none under these fields, on purpose: it keeps "card details go straight to the payment provider" under its own Stripe block, where it knows the fields are a card, and under yours that sentence might be a promise about a card nobody was asked for. If you collect card details, say so from inside your own component.

square-payment-for-shop is the worked example, and shows the shape worth copying: the mode is a setting, not a rewrite. Its hosted checkout page is still there, still the default, and an existing shop keeps it on updating - because on-page entry needs a credential nobody has been asked for yet, and a module update that silently switches a live shop's card payments off is not a feature.

Keeping the site's styling off a button you did not draw: data-cactus-unstyled

The Styles → Buttons panel emits blanket rules - main button, main button:hover, and the same for links, images, fields and labels - and they reach every element of that kind on the storefront. That is right for the site's own content and wrong for anything a third party drew into your module: an Apple Pay or Google Pay button, a chat launcher, a provider's own widget. Those arrive with a look their brand requires, and shoppers recognise them by it.

Put data-cactus-unstyled on the element that wraps them and core's blanket rules skip that whole subtree.

<div data-cactus-unstyled="">
  {/* whatever the SDK draws in here keeps its own look */}
</div>

Worth knowing why this exists rather than an !important counter-rule of your own. The hover rule is background as a SHORTHAND and carries !important (it has to, to beat the Button block's inline base state), so it sets every background longhand - including background-image. A Google Pay button, whose logo is a background image, came out as a flat rectangle of the site's hover colour with the logo and the wording both gone. You cannot override that without knowing the image URL, which is the provider's to change. Not matching the rule at all is the only fix that does not need to know what the button looks like.

Use it only for things you did not draw. Your module's own buttons should follow the site's styling like everything else - that is the whole point of the panel.

Taking the hover colour with you: data-cactus-hover-fg and data-cactus-hover-border

The other half of the same story, and the one that bites the buttons you did draw. The hover rule sets background and, where the site has picked one, color - on the button element. Nothing inside it moves. A button whose whole content is a word inherits that color and is fine; a button that is a small piece of layout is not. Anything within it carrying a colour of its own - a caption in --color-text-muted, an outlined marker, an SVG drawn in currentColor off its own inline color - keeps the colour it had, and that colour was chosen to read against the element's resting surface rather than against the site's hover fill.

Mark such a part data-cactus-hover-fg and it takes the hover foreground for as long as the button is hovered; data-cactus-hover-border does the same for its border colour. Both also match the hovered button itself, so a button whose own colour is set inline can wear one too.

<button data-cactus-hover-fg style={{ color: 'var(--color-text)' }}>
  <span data-cactus-hover-fg data-cactus-hover-border style={{ border: '1.5px solid var(--color-border)', color: 'var(--color-text-muted)' }}>1</span>
  Arm Option
  <svg stroke="currentColor" data-cactus-hover-fg style={{ color: 'var(--color-text-muted)' }}>…</svg>
</button>

The foreground is var(--btn-hover-text, var(--color-text-inverse)): the site's own hover text colour where it set one, so a marked part always agrees with the button around it, and white otherwise - --color-text-inverse is white in both light and dark, and white is the readable answer on a fill strong enough to be worth setting.

Give it the value inherit - data-cactus-hover-fg="inherit" - and the part takes the button's own colour for the duration instead. The two are the same thing on a site that has set a hover text colour. They differ on a site that has set a hover background alone: there the button's label keeps the colour it was resting in, and a child on the white fallback becomes the one part that does not match, which is the very mismatch this attribute exists to stop. Reach for inherit where a marked part has to agree with a label beside it rather than merely stay legible - a price under an option's name, a "Selected" line under the same:

<span style={{ display: 'grid', justifyItems: 'center' }}>
  <span>{value.label}</span>
  <span data-cactus-hover-fg="inherit" style={{ color: 'var(--color-text-muted)' }}>{priceHint}</span>
</span>

Plain data-cactus-hover-fg stays the default; only the exact value inherit picks the other behaviour.

Neither rule reaches a disabled button. A control the page has greyed out is not offering anything, so it takes no hover fill and needs no repaint to keep up with. Your own :not(:disabled) guard on your own hover rule cannot hold the site's fill off, since that one is !important - which is why the guard lives beside it here instead.

Two things follow from where the rules are emitted. They exist only on a site that has set a hover background or a hover text colour, so on a site with neither, a marked element is exactly as it was and you have not quietly given it white-on-white. And they carry !important for the same reason the rules above do - the colours being overridden are inline, and inline beats an ordinary rule.

Mark the parts that are muted or outlined against their resting surface. Leave alone anything that already brings its own strong fill and matched text, such as a filled marker in the primary colour: it stays legible on whatever lands behind it, and repainting it loses the distinction it was drawn to make.

Apple Pay and Google Pay: shop.checkout-wallet-buttons

The sibling of the point above, and it exists because a wallet cannot be drawn from that one. Two constraints force it, both coming from the wallets rather than from shop. A wallet is handed the total when it is built, because its sheet quotes the figure the shopper approves - and clientFields from getClientFields is deliberately amount-free, while the amount on a payment intent does not exist until Place order has been pressed. And Apple Pay refuses to open its sheet unless tokenize() is called inside the button's own click handler with nothing awaited in between; Safari drops the user gesture otherwise and the sheet silently never opens, which is a bug with no error message anywhere.

So a wallet runs the flow backwards: it tokenises first, then asks for the order to be placed with the token it already has. This point collects a client component keyed by payment method id, exactly as the card fields are, and the review step mounts it in the running order a shopper reads down: what is still outstanding, your wallet buttons, the card fields, then Place order. The contract (modules/shop/components/public/checkout-wallet-buttons.ts) is { config, getPayer, amount, currency, disabled, onError, placeOrder }. config is the same publishable per-method client fields the card fields get. placeOrder(payload) puts your token on the cactus-shop-place-order event, and shop then does exactly what the button does: creates the order, creates the intent, and posts your payload to /checkout/confirm for your own confirmPayment to charge.

Four things worth knowing before you write one:

  • getPayer is a getter, not a prop. A wallet wants the billing contact only at the moment it is clicked, and the address above it is still being typed into until then. As a prop it would either be stale or rebuild your button on every keystroke - and rebuilding Google's button under a shopper's finger is its own bug.
  • disabled is exactly what disables Place order, and honouring it is not optional. A shopper paying with a thumbprint must not thereby skip the terms tickbox or an unfinished address.
  • The amount you are given is for the sheet, not for the charge. The charge is made against the order the server creates, and shop's own provider seam re-checks the two agree - so a basket that changed while the sheet was open fails cleanly rather than taking the wrong money. Do not treat your figure as authoritative and do not send it anywhere as a total.
  • Draw nothing when the device cannot pay. Apple Pay is Safari's and Google Pay is nearly everybody else's, so most devices offer one of the two and some offer neither. A button that always fails is worse than no button - and if your wallet needs a domain verified first, withhold it until that is done rather than letting the shopper find out.

Same server-only rule as the point above: modules/shop/lib/checkout-wallet-buttons.ts reads the generated registry, so the review block's RSC half sits in its own rscImport file (ShopCheckoutReview.rsc.tsx).

square-payment-for-shop is the worked example, and it pairs this point with core.well-known-files above - Apple will not show its sheet on a domain it has not verified, and the file that verifies it has to be served from a path no module route can reach.

One point for one decision: shop.commerce-mode

Some questions cannot be answered a piece at a time. "Does this shop take money?" is one: the wording on the buy button, where the basket's forward button leads, whether a price may be printed at all, and whether /shop/checkout serves a page are four consequences of a single decision, and a shop that has switched its checkout off while still saying "Add to basket" everywhere reads as broken rather than as either mode.

So shop.commerce-mode collects a provider that hands back the whole shape or nothing:

import type { ShopCommerceMode, ShopCommerceModeProvider } from '@/modules/shop/lib/commerce-mode'

export const quoteCommerceModeProvider: ShopCommerceModeProvider = {
  resolve: async (): Promise<ShopCommerceMode | null> => {
    const config = await getQuoteConfigCached()
    if (config.mode !== 'QUOTE_ONLY') return null   // no opinion: shop behaves exactly as before
    return {
      mode: 'quote',
      addLabel: config.addToQuoteLabel,
      cartCtaLabel: config.cartCtaLabel,
      cartCtaHref: '/quote',
      hidePrices: config.hidePrices,
      hiddenPriceLabel: config.hiddenPriceLabel,
      blockedMessage: 'This shop works by quote.',
    }
  },
}

Three things in that shape are worth copying into any point of your own:

  • Returning null means "no opinion", not "no". A provider that only cares in one of its own modes returns nothing in the others, and the host's own default stands. Answering 'cart' would be claiming a decision the module has not made.
  • The first provider with an answer wins. Two modules disagreeing about whether the shop takes money would leave the storefront half in each, so shop does not merge them.
  • A provider that throws is ignored. An add-on with a broken settings read must not take a storefront down with it, and the host always has a safe answer of its own.

The pure half of the contract - the types, the defaults and the two helpers that decide what a surface prints (commerceModeMoney, commerceModeButtonLabel) - lives in modules/shop/lib/commerce-mode-shared.ts, apart from the resolver that reads the database. That split is what lets shop's client cart components honour the same mode with the same defaults; a client component importing the resolver would drag Prisma into the browser bundle. If you publish a point whose answer both server and client surfaces need, do the same.

Putting a control where a shopper will find it: shop.cart-header-actions

shop.cart-header-actions collects components shop renders on the basket page's own heading row, to the right of "Your basket". quote-for-shop contributes two ("Save basket as a quote" and "Retrieve quote"), each a server component that reads its own settings and returns null when the owner has switched it off.

It exists for a specific problem: a control a shopper has to be able to find without having been told it exists cannot depend on the site owner having dragged a block onto a layout. The right answer is usually both - the point for the default placement, a Puck block for owners who want it somewhere else, and a setting that turns the automatic one off so they never get two.

Contributing a tab that saves with the host's form

shop.product-editor-sections is the worked example of a point whose contributions are not a panel bolted underneath, but a tab inside the host's own form, saved by the host's own Save button. It is worth copying whenever a host page is a form and a contributor needs to add fields to it, because the obvious alternative - every contributor growing its own save button - gives the user three buttons and no idea which one did what.

Two halves make it work:

  • Server components, rendered by the host page, handed to the client shell. The page renders each contributed server component and passes it as a ReactNode prop into the client shell (ProductEditor), which renders it inside a client context provider. Context flows into server-rendered children perfectly well - they are just elements by the time the provider renders them - so a client component nested inside a contributed server component can still call the host's hooks. That is what lets a contribution stay a server component (and do its own data loading) while still taking part in a client-side form.
  • The contributor registers, the host orchestrates. modules/shop/components/admin/product-editor/context.tsx exports the contract: useProductEditorSave({ dirty, save }) hands a tab's unsaved edits to the single Save button (throw an Error from save to fail it - the message is shown against the tab's name); useProductEditorTabBadge(text) puts a count on the tab; useProductEditorCurrency() shares state the host has already loaded so the contributor need not fetch it again. Registration keys off useId() plus the scope's tab id, so a contribution never has to name itself or know where it was mounted.

Rules worth keeping if you build one of these:

  • Make the hooks inert outside the host. They read a context that is simply absent elsewhere, so the same component can be used on a standalone page without branching.
  • Only form fields belong on the Save button. Structural actions (shop-variations' "add an option", "generate variants") still apply immediately, because they are jobs rather than typed-in details. Holding an action back behind Save reads as broken.
  • Contributions save independently, so report per-tab. They are separate endpoints; one failing cannot roll the others back. Say which tab failed rather than a blanket "could not save", and leave the failed tab dirty so nothing is silently lost.
  • Bump requiresModules when you use one of these APIs. A contributor importing @/modules/shop/components/admin/product-editor/context depends on a specific shop version, exactly as requiresCoreVersion works for core APIs. shop-variations and product-attributes-for-shop both pin shop >= 0.1.38 for this reason.

A sibling point, shop.product-editor-media-sections (shop v0.1.216), does the same for a contribution that does not deserve a tab: it renders at the foot of the editor's own Images tab, under the picture grid. shop-variations uses it to contribute the variations it has promoted with "Image up front", which belong beside the pictures they are arranged among and would be absurd as a tab of their own. Everything above still applies (server component, rendered by the page, useProductEditorSave for the save), with two differences worth copying if you add a point like it:

  • No label, no order in the strip. A contribution here is not a destination, so it carries no name of its own; it borrows the host section's heading style and the manifest's order only decides which contribution comes first when there is more than one.
  • The tab scope does the work. Because the panel is rendered inside the Images tab, useProductEditorSave registers against that tab with no extra plumbing - the unsaved dot lands on Images, which is where the admin just clicked, and the failure message names it too.

The general shape: when a contribution is about something the host already shows, put it next to that thing rather than in a tab where the user must go looking for the connection.

Putting your own pictures in the host's grid

Sitting under the grid is still a second place to look. Where a contribution is not commentary on the host's pictures but more pictures of the same gallery, it belongs in the grid, dragged about with the rest. Shop's product-editor/gallery-extras.tsx (shop v0.1.259) is how: a client component rendered inside the Images tab calls useRegisterGalleryExtras(items, handlers), and shop draws those tiles among the product's own, one set of arrows for the lot. shop-variations uses it for the variations promoted with "Image up front".

  • One ordinal space, not two piles. Each contributed tile carries a position: its index in the finished gallery, the host's own pictures counted in. null means "after the host's own", which is where a new one starts. That is the whole reason for the point - a tick box saying which pile goes first cannot express "the oak one, then our two studio shots, then the walnut".
  • Read the order back forgivingly. Reconstruct with the same insert-at-index rule both sides use (modules/shop-variations/lib/gallery-order.ts), so a tile that asked for slot 7 of a four-picture gallery lands at the end rather than being stranded past it. Anything stricter needs the two owners to write to each other every time either changes.
  • The host never decides what a gesture means. It reports a drag, a typed description and a press of the ×; the contributor decides. shop-variations' × is "off the gallery, still on the variation", which shop has no way of knowing and no business guessing - so the tile carries a removeLabel saying so in words.
  • items need not be memoised, handlers must not be depended on. The registration is keyed off the items' contents, and handlers are held in a ref - they close over the contributor's state and change on every keystroke, and re-registering for that would put the grid in a loop.

Handing a picture over rather than keeping it: gallery-add-bus

gallery-extras is for pictures your module owns and keeps. The other case is a picture being handed over - it becomes one of the product's own photographs and your module never thinks about it again. Shop's components/admin/product-editor/gallery-add-bus.ts (shop v0.1.412) is that seam: addImagesToProductGallery([{ url, altText? }]) puts them at the end of the gallery as unsaved edits, which the editor's own Save button then writes like any other picture. A url already in the gallery is ignored, so a sender may repeat itself harmlessly.

It is a plain window CustomEvent ('cactus-shop-product-gallery-add', detail { images }) rather than a hook, and that is the point of it. A contribution to shop.product-editor-media-sections renders inside the shop's own tab, but the contributing module may have no dependency on shop at all - google-ai-studio works on a site with no shop, so it cannot have a static import of @/modules/shop/... anywhere in its graph, because that path does not exist at build time on an install without one. Such a module dispatches the event itself, from a documented name, and imports nothing.

The event is cancelable, and the Images tab calls preventDefault() when it takes the pictures - so dispatchEvent answering true means nothing was listening: no product editor on screen, or a shop too old to know the event. A sender must say so ("saved to your media library, add them with the Add images button") rather than reporting a success that did not happen. That check is what makes the seam safe to use from a module with no requiresModules entry to pin the shop version with.

The wider pattern is worth copying whenever a point's contributors might not depend on the host: publish the contract as an event name and a detail shape, answer with preventDefault(), and let the sender degrade honestly when nobody answers. purchase-orders is the reference for the rest of it - probe for the host's tables rather than its module row, and read across with raw SQL.

A point between two modules that do not know each other

The same seam runs between two peers, neither of which depends on the other. The worked example is google-ai-studio.reference-image-sources: google-ai-studio wants extra reference pictures for its AI photo panel, product-3d-views-for-shop can produce them by capturing stills off a product's 3D model, and neither module has a requiresModules entry for the other. Either can be released first, and each is perfectly useful without the other installed.

Three parts, and each one is doing a job:

  1. The host declares the point and resolves it on the server. google-ai-studio's section component reads the installed modules from prisma.module, matches manifest extensionPoints entries on the point name, honours each entry's permission, and looks the components up in moduleExtensionPointComponents. The resolved elements are then passed down into its own client panel as a prop, exactly as shop passes this very panel into its client product editor - a client component may never import the extension-point map, and does not need to.

  2. Watch for an import cycle. A module that both hosts a point and contributes to somebody else's is imported by the generated registry, so a static import of that registry from the same file is a cycle - lib/modules/import-cycles.test.ts fails the build over it, and names the edge. The fix it prescribes is await import('@/lib/modules/extension-points') inside the async server component, which is fine: it is a server file, and the map is only needed once per render.

  3. The contribution hands its result back over a documented window event, never a callback prop - a server-rendered node cannot be given a function by the client component that mounts it. The contract is the same shape as the gallery seam above: a cancelable CustomEvent ('cactus-ai-reference-image', detail { dataUrl, label }), taken by preventDefault(), so a producer whose host is too old to listen says so instead of appearing to work.

Mount contributions lazily where they are expensive. The 3D picker stands up a WebGL context and downloads a model; the panel keeps it unmounted until the owner presses the button that asks for it. The node itself costs nothing sitting in a prop - React builds nothing until it is rendered.

Replacing a part rather than adding to one

Most points are additive: the host renders its own page and contributions land alongside. Sometimes a contributor instead needs to take over a part the host already renders, because the host's version would be wrong. Shop's shop.product-detail-parts point is the worked example - a product with options has no single price or stock level of its own, so shop's static price and add-to-cart would contradict what the shopper actually chose. Rendering both is not an option; the shopper would see two prices.

The pattern, in modules/shop/lib/detail-slot.ts:

  • The provider claims, the host decides. The contract's claimsProduct(product) runs server-side, once per page, and answers "do you own this one?". The host resolves it once and passes the result down, so a claimed product renders the provider's components and an unclaimed one renders the host's own, untouched. On a site without the contributing module the point is empty and nothing changes at all - which is what keeps the host's defaults honest for everyone who doesn't have the module.
  • The host keeps owning the look. It hands its own CSS class names down as props and the provider renders into them, so a replaced part still looks like the layout it sits in. Otherwise the swap is visible to the shopper as a style change, and the host stops being the single owner of its own chrome.
  • The host hands over its server-loaded data. Shop passes the product's images and price along, so the provider paints immediately from server data rather than flashing empty while it fetches its own.
  • Whoever renders the part owns all of it. Shop deliberately does not apply its own out-of-stock gate to a claimed product: stock lives on the chosen combination, not the parent row. Splitting the decision between host and provider is how you get a button that disagrees with the price above it.
  • Stand down where the author has already done the job by hand. A take-over point assumes the host's part is the only thing doing that job. It often isn't: a layout written before the point existed does the same job with the provider's own granular blocks, dropped in by hand. Take the slot over as well and the page shows the answer twice. So the contract's coveredSlots(blockTypes) hands the provider the block types present in the layout and asks which slots are already covered; the host renders nothing for those. Note it renders nothing rather than falling back to its own part - a layout holding both shop's Price and the provider's price block would otherwise show the parent's static price next to the chosen combination's, and the shopper reads two different prices for one product. The author placed the provider's block deliberately; it wins outright. For pieces the host has no part for at all (an options picker), there's nothing to stand down, so the host instead passes layoutBlockTypes to every slot component and the provider drops those pieces itself.

This is what lets a module change a host's default layout without either one hard-coding the other: shop's starter layout never mentions variations, and shop-variations never edits shop's starter layout. Note which way the knowledge runs - the host passes block-type strings it attaches no meaning to, and the provider is the only side that knows ShopVariantPrice means "price".

Additive contributions to a part that is already claimed

A take-over point has a hard edge: only one provider can win, so once it is claimed, a second module has nowhere to stand. Shop hit exactly this. shop.product-detail-parts hands the whole gallery to shop-variations on any product with options, so a module wanting to add a 3D model to the gallery could not reach the products that most needed it - the ones with variations.

The answer was a second, additive point beside the claim-and-replace one, not a second claimant: shop.gallery-media (modules/shop/lib/gallery-media.ts), whose live consumer is product-3d-views-for-shop. It is worth copying whenever a host part is already claimable but contributions to it should still stack.

  • Additive means every provider resolves, not just the first. The two-prices failure that forces claim-and-replace to pick a winner does not exist here: nothing replaces a part of the host's, so several contributors simply mean several extra thumbnails.
  • A contribution is a bonus, so it must not be able to take the page down. A provider whose load throws is dropped and logged, and the product still sells. Weigh this the other way for a point the page cannot do without.
  • Every renderer of the part must honour it, including the claimant. Shop resolves the contributions once and passes them to whichever gallery renders - its own, the slot provider's replacement, and the provider's granular block that covers the slot. Miss one and installing the contributing module silently does nothing on exactly the layouts that use it.
  • Only client components cross to a client host, and functions never do. The host's gallery is a client island, so Thumbs/Stage carry their own 'use client' boundary and travel as client references; load is server-only and stays behind. This is why a contributor filters its own items rather than the host calling a predicate into it - a function prop would not survive the RSC boundary at all.
  • Presentation hints stay generic. mobileStage: 'immersive' lets a contributed stage ask the phone sticky gallery for the whole strip while it is active, hiding the host's thumbnails. The host does not learn why; a modular layout, a large interactive viewer or something else can use the same hint without a module-specific rule.
  • Give the contributor the knowledge only the host has. Shop cannot know which variation a shopper picked, so it passes null; shop-variations' gallery passes the chosen child product's id. The contract is the same either way, and the contributor narrows itself.
  • Resolve it where the host builds its context, not inside the part. wrapResponsiveRender calls a block's render as a plain function, so a part that awaits is handing it a promise rather than markup. Shop resolves in ShopProductDetail.rsc.tsx alongside the slot and carries the result on DetailPartContext.

The general lesson: when a point is winner-takes-all and you find yourself wanting a second winner, the answer is usually a second point with different semantics, not a bigger claim.

The same contribution as data, for a view that cannot render components

shop.product-detail-images (modules/shop/lib/detail-images.ts) is the additive point above with the components taken out: a provider's load(productId) returns plain image URLs and a placement ('before' or 'after' the product's own), and the host does the rendering. Its live consumer is shop-variations, which contributes the variations an owner has promoted onto the parent's gallery, and its live host is the chrome-free /shop/products/<slug>/details view that a companion module's "Learn more" modal iframes.

It exists because shop.gallery-media could not serve that view. That point hands over a thumbnail strip and a stage, both of which assume an interactive gallery with a notion of what the shopper has picked; the details view is a row of <img>s inside someone else's modal, with no stage and no selection, so there was nothing for those components to render into.

  • Ask for data when the host has more than one kind of renderer. A component contribution binds the contributor to one host UI. The same facts as data serve a gallery, a bare strip, and whatever the host builds next.
  • Placement is the contributor's to state, not the host's to guess. Whether promoted variations sit in front of or behind the product's own photographs is a per-product setting the contributor owns; the host would have to learn about variations to work it out, which is the thing these points exist to avoid.
  • Reproduce the host-visible rule, not just the data. The provider applies the product page's own rules - enabled and promoted only, matrix order, one representative picture each - so the two views cannot drift apart. A second implementation of the selection is the part worth being careful about; the rendering was never the risk.
  • De-duplicate at the join. A promoted variation may carry a photograph the parent also has, and the same picture twice in a short strip reads as a fault. First occurrence wins, so the requested order survives.

A tab in a host's tab strip

shop.product-detail-tabs (modules/shop/lib/detail-tabs.ts, shop v0.1.50) lets a module add a tab to the product page's own strip, beside Description and Specification. Its live consumer is product-downloads-for-shop, whose Downloads tab lists a product's manuals and spec sheets.

It is shop.gallery-media copied almost line for line, and that is the point of mentioning it: once a host has one additive point, the next one is a known shape rather than a design problem. The same rules apply - every provider resolves, a throwing load is dropped and logged, the Panel is a client component, and it is resolved in ShopProductDetail.rsc.tsx onto DetailPartContext rather than inside the part. Three things it does differently are worth stealing:

  • Returning null means "no tab". A contributor with nothing for this product says so, and the strip renders exactly as it did before. Without that, installing a module would put an empty tab on every product in the catalogue - the additive point's version of the two-prices failure.
  • label and order live on the provider object, not the manifest entry. This is the opposite of shop.product-editor-sections, and deliberately. The manifest's label is stripped by the install-time zod schema (lib/modules/manifest.ts declares only point/id/permission/import/component, and zod drops the rest) and is only restored when scripts/sync-module-manifests.mjs writes the raw JSON back on the next deploy. So a manifest-labelled tab spends its first week named after its own id, behind a fallbackLabel() that exists solely to make that survivable. A provider object is always fully present, so its label always is too. Put metadata on the manifest when the contribution is a bare component that cannot describe itself; put it on the provider when the contribution is an object that can.
  • Number the host's own items when you open them to contributions. Shop's tabs had no order at all until this point existed; they were an array in source order. TAB_ORDER in detail-parts.tsx gives them 10/20/30/40 so a contributor can land among them rather than only after them, and an unordered contribution defaults to 50. Spacing by tens is the cheap part; the expensive part is remembering that "after everything" is a poor default when the host's own items have a meaningful order.

One trap this point had to design around, which any host with a dual-compiled file will hit: detail-parts.tsx is imported by both the RSC config and the client Puck editor bundle, so it cannot import lib/modules/extension-points at all - that module's static imports reach prisma, and dragging prisma into the editor bundle breaks the build. The registry is therefore only ever touched from ShopProductDetail.rsc.tsx, which is server-only by construction, and the resolved contributions ride to the part as data. If a host part renders in the editor as well as on the page, resolve elsewhere.

A whole card on a customer's own order page

shop.member-order-panels (modules/shop/lib/member-order-panels.ts, shop v0.1.402) lets a module add a card to /shop/account/orders/<id> - the page a customer opens to look at an order they placed. Its live consumer is reviews-for-shop, whose Review what you bought card carries a row of stars per product on a completed order.

It is shop.product-detail-tabs again, with the product swapped for an order, and it is worth reading as the proof that the shape travels: title/titleFor/order on the provider object rather than the manifest (the manifest's label is stripped at install time, see above), load returning null for "nothing to say about this order", every provider resolved rather than only the first, a throwing load dropped and logged so an extra card can never take a customer's order page down with it, and the host supplying the card, its heading and its styling. Shop renders the panel inside its own OrderCard, so a contributed panel cannot arrive dressed differently from the parcels and the paperwork beside it.

Two things it does that the product-page points do not, both because the subject is a person's order rather than a public page:

  • The host resolves the context, including who is looking. ShopMemberOrderContext carries the order's id, number, status, payment status, customer name and email, the deduplicated product ids on it, and - the useful part - viewerEmail/viewerName/signedIn. A member is known by their account, a guest by the order itself, and shop settles which before any provider runs. A contributor that worked it out for itself would be re-implementing lib/order-viewer.ts, which is the one rule deciding whether anybody may see the order at all.
  • A route behind a contributed panel must check that rule again, itself. The page having rendered a form is not evidence of anything - a browser can post to a module's route directly. reviews-for-shop's POST /api/m/reviews-for-shop/public/order-reviews loads the order and calls shop's resolveOrderViewer before it will accept a word, and takes the reviewer's name and address off the order rather than out of the request body. A route that filed a review under whatever address was posted to it, and stamped it "verified purchase" on the strength of an order id, would be a way of putting words in a customer's mouth.

One more trap, and it is the reason the module's provider file reaches its own helper through await import(...) rather than a static import: the generated public registry statically imports every provider, so a provider whose imports reach back into anything that reads that registry - shop.product-page-resolver, in this case, through the reviewable-product helper - closes an import cycle. lib/modules/import-cycles.test.ts fails on it in plain npm test, which is the only thing that catches it: tsc and eslint are both perfectly happy, and Turbopack fails the production build with "Cannot access 'x' before initialization".

Taking over a host's tab body (not a whole part)

shop.product-attribute-values (contributed by product-attributes-for-shop) is a plain read for any module that wants to group or label products by an attribute the owner chose. listAttributes() returns { id, name } for every attribute defined; valuesFor(attributeId, productIds) returns a Map of product id to the labels ticked against it, absent where nothing is ticked. Variations are products in their own right here, so a variant's own ticks and its parent's both come back through the same call - ask for whichever ids you hold and fall back from one to the other yourself. It names no consumer and carries no consumer's shape, and a shop without an attributes module simply has no provider: look it up through the generated registry (lib/modules/extension-points.public.ts) rather than importing the other module, and do without when it is not there. google-shopping-for-shop uses it to decide each item's shipping_label.

shop.product-detail-spec (modules/shop/lib/detail-spec.ts, shop v0.1.100) sits between the two shapes above: it is a take-over point like shop.product-detail-parts, but it replaces the body of one tab rather than a whole part. Its live consumer is product-attributes-for-shop, which swaps shop's own Specification facts table (SKU, type, weight, dimensions) for the product's attributes grouped into headed sections. A provider's load(productId) returns null when it has nothing for a product, and shop keeps its own facts table - the same "returning null means no take-over" rule the additive points use, which is what stops installing the module changing a product until an attribute is actually flagged for the page.

It is single-winner, like shop.product-detail-parts and unlike shop.product-detail-tabs, and for the same reason: two modules both answering "what is this product's specification" would contradict each other, so the first provider with a non-null payload wins and the rest stand down. But it does not claim the whole product the way shop.product-detail-parts does - the tab, its name, its place in the strip and its styling all stay shop's, so this take-over composes cleanly with a variation product that shop-variations has already claimed for pricing. When a take-over is scoped tightly enough (one tab body, not the page's identity), the single-winner edge that bites the parts point never comes up. Resolved in ShopProductDetail.rsc.tsx onto DetailPartContext.specOverride and consumed in buildDetailSections, for the same dual-compile reason as the tabs point above.

The panel props also carry an optional layout hint, autoSort?: boolean, fed from the Sections block's Auto-sort specification groups field. It is a hint, not a contract: a provider whose body is a set of groups may re-order them for the tightest column fill, and a provider with no notion of groups ignores it.

Answering a metadata question for the host's page

shop.product-social-image (modules/shop/lib/product-social-image.ts) is the smallest shape yet: a single-winner point whose provider is a plain server-safe function answering one question - which picture will this product's page open on for THIS request? Shop's generateMetadata asks it before falling back to the product's own first photograph, and the answer becomes the page's og:image, so a shared link's social preview shows the configuration the link carries rather than the stock photo. Its live consumer is shop-variations, whose provider replays the gallery's own opening-image derivation against the request's picks.

Two request-scoped conventions make it work, both the same cache() slot trick as shop-variations' bootstrap:

  • modules/shop/lib/product-page-params.ts - shop parks the product page's raw searchParams here (from generateMetadata and the page component, whichever runs first), and any companion module reads back the parameters it owns. Shop never learns what seat-colour=oxford-blue or pad=... mean; it only holds the map. This is also how a shared link's selection reaches getVariationBootstrap during the page render, so the option controls arrive in the first HTML already set.
  • The deep-link preselection slot (rememberPreselectOptionValues) - already written by the shop.product-page-resolver provider before the social-image point is asked, so a variant's own URL and a parametered parent URL resolve through one code path.

The provider runs inside generateMetadata, so everything it touches must be server-safe, and it must never throw for a malformed parameter - an unreadable pick means "no answer", never a broken page. Note the resolution order in shop's generateMetadata: park the search params, resolve the product (running the page resolver for a deep link), then ask this point - a provider asked before the deep link is recorded would answer for the wrong configuration.

shop.product-canonical-query (modules/shop/lib/product-canonical.ts, shop v0.1.261) is the same shape again, answering a different metadata question: do these query parameters name something worth indexing on its own? Shop's generateMetadata normally emits the bare product URL as the canonical, which is right - option choices in a query string describe the same page, and folding them onto one address is the tag's job. It stops being right the moment a module both (a) publishes those addresses in the sitemap and (b) can say which of them resolve to a real, buyable thing. A provider returns the canonical query string for the configuration this request names, or null to decline, and shop keeps the bare URL. Its live consumer is shop-variations, whose lib/sitemap.ts lists one URL per enabled combination and whose provider hands back exactly the address that file published.

Three rules make this point safe to answer, and they are the interesting part:

  • Decline for a partial selection. A half-chosen listing is the listing with some controls pre-set, and there is no end to the number of half-chosen addresses a crawler can invent. Only a complete combination that resolves to a real variation gets an address of its own.
  • Answer from the thing, not from the request. The variations provider spells its answer from the variation's own option values in display order, never from what was typed - so an alias value and a hand-reordered query string both fold onto the one address, which is what a canonical is for.
  • Hand core the URL raw. Next pastes each sitemap entry's url straight into <loc> with no escaping of its own, and the sitemap protocol wants entity-escaping there - one unescaped & from a query string ends the XML document at that line and loses every entry below it. app/sitemap.ts escapes the lot in one place (lib/seo/sitemap-xml.ts, core 0.5.1151), so a module's getPublicSitemapEntries returns plain URLs and must not pre-escape them, or they come out double-escaped.
  • Publish and declare from one function. Both sides call buildVariationQuery in lib/url-selection.ts. A sitemap advertising one spelling while the page declares another is worse than having neither: the page would be disowning the exact URL the sitemap just recommended.

shop.product-selected-variation (modules/shop/lib/product-selected-variation.ts, shop v0.1.424) is the structured-data half of the same question, and it deliberately answers it less strictly. The canonical point asks is this address worth indexing on its own?; this one asks what is the shopper actually looking at? A provider returns the combination this request resolves to - its child product id, its canonical query string, its price, its "was" price, its RRP, whether it is in stock and its own photographs - or null to decline, and shop's Product markup carries on describing the listing with an AggregateOffer spanning every choice.

Two things about it are worth copying if you ever fill it:

  • Return money in the shop's own stored terms. Whether the caller wants to print net or publish gross is the caller's business, and converting inside the provider settles that question in the wrong file. Shop applies makeGrossAdjuster (modules/shop/lib/tax-display.ts) on the way out, because structured data owes a shopping channel the VAT-inclusive figure whatever the storefront prints - a trade catalogue showing "£126.00 ex. VAT" beside markup saying 151.20 is correct, and markup agreeing with the page instead is how a correct feed row gets pulled for a price mismatch.
  • Do not return identifiers. Brand, barcode and part number come back through shop.product-merchant-facts keyed on the productId this point hands over, so one rule decides them whether the asker is a product page or a feed. A combination is asked about in its own right, never by inheriting its listing's - a listing's barcode on forty colourways claims they are all the same part.

The selection maths is the storefront's own (withStrandedFilled, withAutoSelected, resolveVariant), which is why the point does not decline a half-made or unbuyable selection the way the canonical point does. Name one option and the page settles the rest; name a combination the owner has switched off and the page moves you to one that sells. Either way there is one chair on screen at one price, and the canonical tag - same derivation - already names it. The markup describes the page that rendered, not the URL that was typed.

Shop only asks the point when the request actually carries a query string, so an ordinary product page pays nothing for the mechanism existing.

shop.products-tabs (modules/shop/app/cactus-admin/shop/products/page.tsx, shop v0.1.92) lets a module add a whole-page tab beside the built-in list on the Products admin page. Its live consumer is shop-variations, whose Variations tab lists every variation across the shop with per-product and missing-image/missing-file filters. Use it when a module has a catalogue-wide view that belongs next to Products rather than as its own sidebar entry.

It is the simplest tab point of the lot, because there is no shared form to save into (unlike shop.product-editor-sections): a contribution is just a self-contained screen.

  • The host resolves the tabs, the client screen owns the strip. resolveProductsTabs reads each active module's manifest, permission-filters per entry, and builds ProductsTab[] ({id, label, order, node}) passed into the client ProductsScreen with initialTab (from ?tab=). The node is the contributed component already rendered - a client component here, since it is interactive and self-fetching, but a server component would work exactly as shop.product-editor-sections does.
  • Render only the active tab. ProductsScreen shows a TabStrip only when at least one tab is contributed (a plain shop is byte-for-byte unchanged), keeps "Products" as the first tab, and mounts only the active tab's node - so a contributed screen does not fetch until it is opened, and the host's own products fetch is skipped while a contributed tab is active.
  • label/order on the manifest entry, with a fallback. Like shop.product-editor-sections, these ride as extra manifest fields the generator ignores; the install-time schema strips them until the next deploy restores them, so the host derives a tidy label from the entry id in the meantime. Pick an id that reads well on its own (variations, not shop-variations-list) so that fallback window is invisible.

A column in a host's table

shop-variations.variant-columns (modules/shop-variations/components/admin/ProductVariationsSection.tsx) hangs an extra column on the variants table, one cell per variant. Its live consumer is product-3d-views-for-shop, whose 3D column sits beside the Image column and takes a dropped model file for that variation.

It exists because the alternative was worse in a way worth spelling out. Setting a variation's picture and setting its 3D model are the same errand, but the picture lives in shop-variations' table and the model belongs to a module shop-variations has never heard of and does not depend on. Teaching the table about 3D would have put one module's UI inside another's - the exact thing module isolation is for - so the table learned about columns instead, and knows nothing about what any of them are for.

  • The host leaves a gap; it does not describe the contents. A cell gets productId, variantId, childProductId and label, and owns everything after that: its own storage, its own fetching, its own saving. shop-variations has no 3D code in it, and a site without the 3D module installed has no such column.
  • childProductId is the knowledge only the host has. A variation is a hidden child product, and its id is what a contributor needs to attach anything to it. Passing it is the whole point of the contract - the same lesson as shop.gallery-media above.
  • A contributed cell must be a client component. The table is a client island that fetches its own variants, so the cell renders in the browser once per row; only client references cross that boundary. ProductVariationsSection is a server component and resolves the manifests, the permissions and the registry, because none of that can happen in the browser.
  • Contributed cells save themselves. The rest of the table registers its edits with the product editor's single Save button, and this deliberately does not. The columns this point exists for carry uploads, and holding a 40 MB file in memory as a pending edit - lost on a tab change, applied later - would be a lie that costs the admin their upload. Weigh it the other way for a column of typed-in text.
  • label and order ride along on the manifest entry. Same trick as shop.product-editor-sections, with the same fallback rule: a manifest written before the field existed will not have it.

Core helpers a module may import

A module must never import another module's code. Where two modules want the same machinery, it belongs in core and both import it from there.

Shrunk copies of a library picture - lib/media/renditions.ts:

import { generateImageRenditions } from '@/lib/media/renditions'

const made = await generateImageRenditions(url, [
  { maxPx: 400, suffix: 'small' },
  { maxPx: 128, suffix: 'tiny' },
], { worthwhileBytes: 100_000 })
// -> { small: 'https://.../oak-small.webp' | null, tiny: ... }

One download and decode feeds every size, so ask for all of them in one call rather than once each. Each copy is filed beside its original, in the same folder, named <original>-<suffix>.webp, and marked as already optimised. A null back means there was nothing worth making - the url is not a library item, the format is not one to shrink (only JPEG, PNG and WEBP are), or the original is already inside maxPx and under worthwhileBytes. It never throws: a failure is logged and comes back null, because this runs inside ordinary saves and losing an owner's edit over a thumbnail would be the tail wagging the dog. Store what you get, and fall back to the next size up when reading.

Registering a core.media-reference-rewriters and a core.media-usage-providers entry for the copies' columns is not optional: each copy is its own library item with its own url, so without them a move or an optimise strands them and the library offers them up as unused.

And run every media url through repointToCurrentMediaUrls on the way IN - lib/media/former-addresses.ts. A rewriter fixes the references that exist when a blob moves; it can do nothing about one you are storing now against a url the editor screen captured before the move. That is a picture that 404s the moment it is saved, and an item that reads as unreferenced. Core remembers every address an item has lived at, so await repointToCurrentMediaUrls(urls) hands back the live address for each one (and leaves anything it does not recognise alone). One line in whatever function writes your url column - shop does it in setProductMedia. The safety net underneath it is automatic: a reference to an old address already counts as a reference, so the item is never offered up as unused either way. This only stops the broken picture.

The swatch sizes, and what to say about them - lib/media/swatch-renditions.ts. Client-safe (no sharp, no prisma), so an admin screen can import it. SWATCH_SMALL_MAX_PX (400) is sized for a product page's enlarged hover preview; SWATCH_TINY_MAX_PX (128) for dots and little tiles on listings. describeSwatchRenditions(full, small, tiny, files) returns the three boxes an admin screen draws, each with a title, the sentence to put in its tooltip, and whether the storefront is currently drawing it. Use these rather than inventing your own numbers, or three modules will tell one owner three different stories about the same three files.

The admin's own tooltip - components/admin/Tooltip.tsx:

import { AdminTooltip } from '@/components/admin/Tooltip'

<AdminTooltip title="Tiny copy" body="5 KB, 128 x 128. Drawn by category cards and filter lists.">
  <SomeLittleBox />
</AdminTooltip>

Portalled to <body> and fixed-positioned, so a table cell or a scroll container cannot clip it; shown at once on hover or keyboard focus; flips above or below by available room. Reach for it wherever a tooltip is carrying real information rather than repeating a label - a native title waits about a second, cannot be styled, wraps however the browser fancies, and never appears for a keyboard user at all. If you wrap a control that also sets title, turn the native one off so the two do not stack.

The admin's own "are you sure?" - components/admin/useAdminConfirm.tsx:

import { useAdminConfirm } from '@/components/admin/useAdminConfirm'

const [askConfirm, confirmNode] = useAdminConfirm()
// ...
if (!(await askConfirm({ title: 'Delete this?', message: 'This cannot be undone.' }))) return
// ...and render {confirmNode} once, anywhere in the component.

Use it instead of the browser's confirm(), which cannot be styled and which some browsers let people switch off for the page - after which every Delete on it does nothing. The confirm button is red and says "Delete" unless you pass danger: false and a confirmLabel. It is drawn into <body>; pass { inline: true } only when the trigger sits inside a dropdown that shuts on a press outside itself (the bell does this). Escape cancels the dialog without reaching anything underneath it.

The same file exports useAdminPrompt() for the browser's prompt(): const [askText, promptNode] = useAdminPrompt(), then await askText({ title, message?, defaultValue?, placeholder?, confirmLabel? }) resolves the text as typed, or null if cancelled. For an alert that only needs an OK, call askConfirm with confirmLabel: 'OK', cancelLabel: '' and danger: false.

Module cron jobs

A module can register scheduled jobs by declaring cronJobs in cactus.module.json:

"cronJobs": [
  { "path": "/api/m/my-module/cron/sync", "schedule": "0 6 * * *" }
]

path is dispatched through the same generic /api/m/[module]/[...path] router as any other module API route - just create app/api/cron/sync/route.ts in your module and it's reachable at that path. schedule is a five-field cron expression (minute hour day-of-month month day-of-week), evaluated in UTC.

How the schedule is actually run

The site registers exactly one cron with Vercel: /api/cron/dispatch. That dispatcher reads every installed module's cronJobs (and core's own) and calls the ones that have come due since they last ran, recording each job's last run in the core CronRun table. The tick is hourly by default and speeds up on its own if any job on the site is set to run more often.

There is one entry rather than one per job because Vercel's Hobby plan allows two cron jobs per project, and core plus the shipped modules declare more than twenty - a per-job list would stop a Hobby site deploying at all. One entry fits every plan, and it never has to be rewritten as modules come and go.

What that means when you write a schedule:

  • Your schedule is honoured to the tick, not to the minute. The dispatcher wakes hourly on a paid plan by default, so 30 6 * * * runs at the 07:00 tick. On Vercel's Hobby plan the dispatcher itself only runs once a day, so everything runs once a day - as it always did there.
  • What you declare is the default, not the last word. The site owner can change any job's frequency in Settings → General → Schedules, and the tick speeds up to match. Declare the schedule your module actually wants; it will be kept unless somebody deliberately changes it.
  • Design for "runs roughly daily" and pair anything time-sensitive with a manual "Check now" button in your admin screen.
  • A job is never run twice for one missed window. If the site was asleep for a week, a nightly job runs once when it wakes, not seven times.
  • The first time a job is seen it is recorded, not run. Installing your module does not fire its cron immediately; the job starts on its own schedule from the next tick.
  • Supported syntax is *, a number, a-b, a,b,c, and */n or a-b/n. Names (MON, JAN), ?, L, # and seconds are rejected - a schedule core cannot evaluate is logged and skipped rather than quietly never firing, so check your expression parses.
  • The path must be your own. A cronJobs entry pointing anywhere other than /api/m/<your-module-name>/... is refused, because the dispatcher calls it with core's CRON_SECRET attached.

The one entry lives in a committed vercel.json at the repo root. It has to be committed: Vercel reads vercel.json out of the commit when it creates the deployment, so a vercel.json written during npm run build is written after the only moment anything reads it. Core writes the file into an install's repository as part of the core update and the module registry sync (lib/cron/vercel-file.ts, lib/updates/core.ts, lib/modules/github.ts).

Putting the frequency on your own settings tab

Every job already appears in Settings → General → Schedules, so you do not have to do anything. But if the frequency is part of what your module is - a mail check, a stock import - the owner will look for it on your tab, not in core's list. Core exports the control for exactly that:

import { CronScheduleSetting } from '@/components/admin/CronScheduleField'

<CronScheduleSetting
  path="/api/m/my-module/cron/sync"
  label="How often to check"
  hint="Anything faster than your site currently wakes up will rebuild the site."
/>

It fetches its own state, saves through core's /api/admin/cron, and reports back whether a deploy was started. You need no route, no table, no settings key and no knowledge of the dispatcher. It renders nothing at all if the path is not a job this install has, so an older core or a manifest that failed to parse costs you a missing control rather than a broken tab.

If you are building your own layout around it, CronScheduleField (the bare select), saveCronFrequency and describeSaveResult come from the same file. Both need the viewer to hold config.manage - the API refuses otherwise, so gate your panel accordingly or accept that it will show an error to anyone else.

Run now. runCronJob(path) and describeRunResult(result), from the same file, give you the button that appears beside every job in Settings → General → Schedules. It calls POST /api/admin/cron/run, which runs your job through the same helper the dispatcher uses and waits for it - a job that takes half a minute keeps the promise open for half a minute, because "it has been asked to" is not an answer anybody can act on. It records the run exactly as a tick does, so a job run by hand resets its clock: a nightly job run at four in the afternoon is next due the following night. Disable your button while a call is in flight; a second press would start a second copy of something already running.

Authenticating cron requests. Nothing changes here: the dispatcher calls your route with Authorization: Bearer $CRON_SECRET, exactly as Vercel used to. Your cron route just checks that header:

const secret = process.env.CRON_SECRET
if (!secret) return errorResponse('CRON_SECRET is not configured', 503)
if (request.headers.get('authorization') !== `Bearer ${secret}`) return errorResponse('Unauthorized', 401)

Do not declare CRON_SECRET in requiredEnvVars. Core owns that variable and mints it itself (lib/vercel/cron-secret.ts): it is generated during setup, and installing any module whose manifest declares cronJobs provisions one on a site that has none, writing it to the Vercel project before the install's redeploy. Declaring it required only risks dead-ending an install on a value the platform was going to create anyway. It arrives in process.env with the deploy the install triggers, never during the install request itself - which is why the 503 guard above still belongs in the route.

Letting a public API route be cached

A storefront page whose HTML is answered by a CDN still fires its client-side islands at your site: the reviews strip, the related products, the variation selector. Once the pages themselves are cached, those calls are most of what wakes a function at all, and each one is a full database round trip for an answer that was the same for the last hundred people who asked.

A route that depends only on its own URL and the site's settings - and on nothing whatsoever about who is asking - can say so:

export async function GET(request: NextRequest) { /* … */ }

// Seconds a shared cache may hold this answer.
export const publicCacheTtl = 300

Core does the rest. The dispatcher passes the response through lib/cache/module-api-cache.ts, which adds the header only when all of the following hold, so the rules live in one place rather than being re-implemented in every module:

  • the request is a GET or HEAD and the answer is a 200;
  • the route did not set a Cache-Control of its own (yours wins if it did);
  • the request carries none of the cookies that mean "this visitor's answer is their own" - core's admin and member sessions, plus anything any installed module declared in cacheBypassCookies;
  • the site owner has Keep ready-made copies of your pages switched on.

Declarations are capped at an hour. A number that is not a positive one is ignored.

Say nothing unless you are certain. The test is not "does this route read a cookie" but "could two people asking the same question at the same moment be owed different answers". Anything personalised, anything that varies by signed-in state, and anything carrying a one-time token belongs uncached. Get it wrong and a CDN hands one visitor's answer to everybody for the whole window - which is exactly the kind of bug that shows up in somebody else's basket rather than in your tests.

Live prices and stock deserve a short window rather than none. Sixty seconds still absorbs a burst, and a shopper who picks something that sold out in the meantime is stopped at the checkout, which re-checks everything for real.

An older core ignores the export. It is a plain constant, so a module carrying one installs and builds against a core that has never heard of it - it simply does not get cached there. No requiresCoreVersion bump is needed for this on its own.

Note that module route files cannot export their own maxDuration - the generated router imports every route file as a plain object of HTTP-method handlers, and a maxDuration export breaks that structural type. The shared dispatcher at app/api/m/[module]/[...path]/route.ts sets one maxDuration (currently 60s) for every module route instead.

Catch your own errors and say what went wrong. An uncaught throw in a route handler is masked by the framework into a bare "Internal Server Error", so all the cron dispatcher can record is HTTP 500 and all the owner sees on Settings → General → Schedules is that the job did not finish. Wrap the work and answer with the message instead - it is the difference between a job somebody can fix and one nobody can:

try {
  const result = await doTheWork()
  return NextResponse.json({ ok: true, ...result })
} catch (err) {
  return errorResponse(err instanceof Error ? err.message : 'the run failed', 500)
}

Budget your run against the tick, not against the ceiling. The dispatcher hands a job whatever is left of its own 60-second tick and still has to write the outcome down afterwards, so a job that helps itself to the full 60s will be cut off. Bound long work to something comfortably short of that and report partial progress, as the SEO audit does.

Raw SQL is executed by nothing until it runs on a live site. tsc and eslint treat a query as a string, and the build gate never sends one to Postgres, so a statement that Postgres refuses is green everywhere until the night the cron fires. One trap worth knowing: Prisma binds a JavaScript integer as bigint, so make_interval(days => ${n}) fails with 42883: function make_interval(days => bigint) does not exist - write ${n}::int4. Core's lib/db/raw-interval-casts.test.ts fails the whole test run on an uncast one, in any module it can see.

Public routes

Most modules only need admin pages and API routes. A module that also needs a public-facing area of the site (a blog, a forum, a directory) can declare a single top-level URL segment it owns:

"publicBasePath": "gazette"

publicBasePath must be a single lowercase URL segment (letters, digits, hyphens). It is validated for uniqueness at install time against every other installed module's publicBasePath, and against existing InfoPage slugs - installing a module whose base collides with an existing page, or creating/renaming a page to match an installed module's base, is rejected with a 409. An InfoPage always wins a collision that slips through (e.g. a page created before the module was installed) - this never causes data loss, it just hides the module's public index until the page is renamed or removed.

Putting content on a bare slug

A module that wants its content at /<slug> rather than /<base>/<slug> - a blog whose posts sit at the top level, say - can additionally declare:

"publicRootSlug": {
  "page": "./app/root/[slug]/page",
  "claimImport": "./lib/root-slug",
  "claimExport": "myModuleClaimsRootSlug"
}

claimExport names a (slug: string) => Promise<boolean> that answers whether your module owns that slug; page names the page component core renders when it says yes, receiving params: { slug } like any other page. Both are imported lazily, so neither may pull client code in at the top level.

Core asks the claim last - after it has ruled out an InfoPage and a module index at that slug - so core content always wins and your claim only ever runs on what would otherwise be a 404. Keep it cheap and make it return false fast when the feature is switched off.

Two things to get right:

  • Put the page outside app/public/<base>/. Everything in that directory is also mounted under /<base>/, so a page kept there would answer at both addresses. Gazette uses app/root/[slug]/page.tsx.
  • Keep the old address alive. Leave a page at app/public/<base>/[slug]/page.tsx that permanentRedirects to the new one, or every link already out in the world breaks the day you switch.

Because a bare slug shares its space with pages and other modules' bases, your slug generator should treat getInstalledPublicBasePaths() and the existing InfoPage slugs as taken - otherwise a colliding slug simply never resolves to your content.

Conventions

Place public pages and routes under app/public/<base>/ in your module, mirroring the Next.js App Router conventions you'd use for app/:

modules/my-module/app/public/my-module/
├── page.tsx                # /my-module (index)
├── [slug]/page.tsx         # /my-module/<slug>
├── archive/[year]/page.tsx # /my-module/archive/<year>
└── feed.xml/route.ts       # /my-module/feed.xml (RSS or similar)
  • page.tsx files export a default React component and may export generateMetadata, same as any Next.js page.
  • route.ts files export GET/POST/etc, same as any Next.js route handler.
  • scripts/generate-module-router.mjs scans this directory and generates resolveModulePublicPage, dispatchModulePublicRoute and getModulePublicBases in lib/modules/router.public.ts (gitignored, regenerated on every build/dev start). The admin and API loaders go into a separate lib/modules/router.ts, because a route that can reach a loader ships the client components behind it - see Why the router is two files in the Architecture overview.
  • Core resolves /<base> via app/(public)/[slug]/page.tsx's fallback path (InfoPage lookup first, module index second) and /<base>/<...rest> via the generic catch-all app/(public)/[slug]/[...path]/page.tsx. The single-segment route reads only your index page and your root-slug claim, from lib/modules/router.public-slug.ts, so keep your index page (app/public/<base>/page.tsx) light: whatever client components it can reach are shipped with every info page, product page and filter landing page on the site, rendered or not. Put anything heavy behind a small 'use client' wrapper that uses next/dynamic (see space-planner-for-shop/components/public/SpacePlannerLazy.tsx). Deeper pages are only reachable from the catch-all.
  • Never import a Node built-in (crypto, stream, buffer) from a file a client component imports, even for one helper. The browser bundle gets a polyfill for it, about 121 KB gzip for crypto, on every page that component can appear on. Keep the Node half in a separate server-only file (see quote-for-shop/lib/code-server.ts). lib/modules/public-graph-weight.test.ts fails on it. A literal app/(public)/[slug]/feed.xml/route.ts delegates /<base>/feed.xml specifically, since a route.ts can't share a folder with a page.tsx.

Rendering is always dynamic

Every module public page renders per-request (force-dynamic) - there is no static generation or ISR for module public routes. This is deliberate: modules like Gazette need scheduled content to appear exactly on time with no cron job, which only works if the page is never cached. Budget for this when designing a public page that does expensive work; cache within your own module's DB queries rather than relying on route-level caching.

Sitemap entries

If your module has public pages worth indexing, add lib/sitemap.ts to your module exporting:

export async function getPublicSitemapEntries(siteUrl: string): Promise<MetadataRoute.Sitemap> {
  // return an array of { url, lastModified, changeFrequency, priority }
}

The generator wires this into collectModuleSitemapEntries(), which core's app/sitemap.ts calls and appends to the site sitemap. Errors are swallowed per-module so one broken module's sitemap code can't take down the whole sitemap.

Site-wide head contributions

If your module has something that belongs on every public page - structured data describing the site's owner, a verification meta tag - add lib/head.ts exporting (core 0.5.1418+):

export async function getPublicHead(siteUrl: string): Promise<{
  jsonLd?: object[]
  meta?: Array<{ name?: string; property?: string; content: string }>
  links?: Array<{ rel: string; href: string; type?: string; title?: string; hrefLang?: string }>
  scripts?: Array<{ id: string; content: string }>
}> {
  // return the JSON-LD blocks, meta tags, link tags and boot scripts for this site
}

The generator wires this into collectModulePublicHead(), which core's app/(public)/layout.tsx calls once per public page render. JSON-LD objects are serialised with < escaped and rendered as <script type="application/ld+json"> in the body - search engines read JSON-LD from the body as happily as from the head, which is what makes a layout contribution possible at all. Errors are swallowed per-module. Every field is optional: a contributor that returns none of one is simply skipped for it.

scripts are plain inline scripts rendered first in the body, ahead of the header and every block, so they run parser-blocking before anything paints - the same job core's own theme-init does for dark mode. They exist for one kind of work: applying a visitor's saved preference before the content it governs is drawn, because a cached public page cannot know the visitor on the server. Shop uses it for the shopper's with/without VAT switch (modules/shop/lib/head.ts, lib/tax-view-shared.ts).

Do not keep the preference as an attribute on <html> or <body>. React owns both as "singletons", and whenever it takes one over on the client - recovering from a hydration mismatch, for one - it strips every attribute it did not render itself (commitHostSingletonAcquisition in react-dom). The VAT switch first shipped that way and shoppers had to choose again on every page. Write it into a <style> (or <script>) element your script adds to <head> instead: React deliberately leaves those alone. Shop's switch keeps its state as an attribute on its own <style id="shop-tax-view-css"> and puts the CSS for the chosen side inside it. Keep them tiny, ES5, self-contained and wrapped against a storage exception; give each a unique id; never interpolate anything a user typed. An entry missing its id or content is dropped. An older core ignores the field entirely, so set requiresCoreVersion to the core release that renders it before relying on it.

Two rules, both of which cost a real site if you get them wrong:

  • Cache your reads. This runs on the render path of every public page. Wrap the settings lookup in React's cache() so it is one query per request, not one per call - a page that got slower for a tag nobody can see is not an SEO improvement.
  • Never throw, and default to silence. A settings table mid-migration, or a database having a moment, must cost the page its structured data and nothing else. Return an empty contribution rather than letting an exception reach the layout.

Do not use this hook for per-page metadata. Titles, descriptions, canonical URLs and Open Graph tags belong in your page's own generateMetadata - core fills in whatever you leave blank (see below).

What core fills in for you

Core 0.5.1418 wraps every module page's generateMetadata result in withPublicSeo (lib/seo/public-metadata.ts), which fills in - only where your page left them blank:

  • alternates.canonical, from the address being served
  • openGraph.url, openGraph.type (website), openGraph.siteName, and openGraph.title / openGraph.description mirrored from your title / description
  • twitter.card (summary_large_image), plus title and description

metadataBase is set on the root layout from SITE_URL, so a relative og:image path resolves to a full address.

Anything your page sets for itself wins. A paginated listing that wants its canonical pointing at page one, or a post that is an article rather than a website, just says so and core leaves it alone. Shop's per-variation canonical (shop.product-canonical-query, above) works this way.

Local development loop

  1. Clone your module into a local Cactus install's modules/ directory:
    git clone https://github.com/you/cactus-module-forum modules/forum
  2. Apply your migrations directly:
    # From the repo root:
    node scripts/run-module-migrations.mjs
  3. If your module needs a Module database row:
    INSERT INTO "Module" (id, name, "repoUrl", version, "tablePrefix", status, "installedAt")
    VALUES ('dev-forum', 'forum', 'https://github.com/you/cactus-module-forum', '0.0.1', 'forum_', 'active', NOW());
  4. Start npm run dev - it automatically runs generate-module-router.mjs first, wiring your admin pages and API routes. Visit /cacti/m/forum/<page> (or wherever your nav entry points).

Cookie consent gate for modules

If your module sets non-necessary cookies (analytics, advertising, third-party embeds, etc.), you must follow the consent gate contract.

1. Declare the category in your manifest

{
  "cookieCategories": ["analytics"]
}

This tells the admin consent banner editor that your module uses these categories and surfaces them as one-click suggestions. The admin still decides whether to add the category to their site's list.

2. Gate scripts that set non-necessary cookies

Never load tracking scripts unconditionally. Use the gate utility:

import { loadIfConsented, onConsentChange } from '@/lib/consent/gate'

// Run fn immediately if consent exists, else defer until the visitor consents
loadIfConsented('analytics', () => {
  // inject your script here
})

// Or subscribe to changes (e.g. if the visitor upgrades from reject to accept)
onConsentChange((decision) => {
  if (decision.analytics) {
    // inject
  }
})

You can also check window.__cactusConsent.analytics directly, but hasConsent() from gate.ts is safer (handles SSR and missing window).

In module code, prefer the window event. onConsentChange keeps its subscribers in a module-level Set inside core's gate.ts, and a separately bundled module cannot assume it shares that instance. Core announces every decision on window as well, which crosses bundles and needs no import:

window.addEventListener('cactus:consent-change', (e) => {
  const granted = (e as CustomEvent<Record<string, boolean>>).detail['live-chat'] === true
})

For a React component that must appear and disappear with the decision, read it as an external store rather than mirroring it into state:

const granted = useSyncExternalStore(
  (cb) => { window.addEventListener('cactus:consent-change', cb); return () => window.removeEventListener('cactus:consent-change', cb) },
  () => (window as unknown as { __cactusConsent?: Record<string, boolean> }).__cactusConsent?.['live-chat'] === true,
  () => false,          // server snapshot - never granted during SSR
)

3. Gate the UI too, not only the script

If your module's visible control is the processing - a chat launcher, a video embed, a third-party map - do none of that processing until the category is granted, rather than showing the control and gating what it loads. A visitor who has not answered the banner yet has no decision recorded, so a single === true check covers both "has not chosen" and "chose no". Decide server-side whether the gate applies at all (banner enabled and the site's category list actually carries your key), because the client cannot tell "no banner" from "not granted" - core defines window.__cactusConsent either way. The live-chat module's WidgetLoader is the reference implementation.

Rendering nothing is the safe default, but consider an inert placeholder where the visitor might come looking for the feature: a control that simply vanishes reads as "this site does not have that", and someone who declined is left with no route back. Say what is missing and offer window.cactusConsent.open() underneath - it reopens the banner's manage view on the visitor's existing choices (core v0.5.1098+; older core opens it on the site defaults, so saving from there would reset their other categories). The placeholder must stay inert: no script, no cookie, no request. To word it differently for "not asked yet" versus "asked and declined", check whether the cactus-consent cookie exists - window.__cactusConsent flattens both to false. Read its existence only; the payload inside is core's to shape.

4. What Cactus cannot gate for you

If operators paste third-party script tags directly into page HTML (e.g. a raw Google Analytics snippet), Cactus cannot suppress them. Document this clearly if your module encourages such patterns.

Known constraints

  • Public repos only. The install flow fetches the manifest from GitHub. Private repos aren't supported.
  • No shelling out to git. All installs and updates go through the GitHub REST API. There is no filesystem mutation at runtime.
  • Migrations run during the build step, never at runtime. An API route that calls the migration runner will throw in production - Vercel's filesystem is read-only.
  • Declare teardown to enable full uninstall. Without it, admins can only remove the code - database tables are left behind. Declare the exact PascalCase table names Prisma created (e.g. "ForumThread", not forum_threads).
  • tablePrefix is permanent. Once installed, the prefix cannot be changed. Choose it carefully - it's used in every table name and in ModuleMigration records.
  • Render markdown in client components with the browser-safe renderer, never @cactus/lib/sanitize. The server sanitiser pulls in jsdom, which must never reach the client bundle - importing it into a 'use client' component throws at render in the serverless runtime. Use a window-backed renderer (marked + DOMPurify(window)) that shares the same allow-list, so its output matches the server sanitiser. Server components and API routes keep using @cactus/lib/sanitize.
    • Vendor it inside your module - do not import @cactus/lib/markdown-client. Core ships that file, but it only exists in cores from 0.5.114 onwards. A module that imports it fails to build on any install whose core predates it (the build clones the latest module against whatever core that install happens to be on). Keep your own copy under lib/ so your module is portable across core versions. The contact-form module's lib/markdown-client.ts is the reference implementation.

Minimal complete example

Here is the smallest possible working module:

cactus.module.json:

{
  "name": "announcements",
  "version": "1.0.0",
  "tablePrefix": "ann_",
  "description": "Site-wide announcements.",
  "requiredEnvVars": [],
  "navEntries": [
    { "label": "Announcements", "path": "/announcements", "icon": "<path d=\"M4 9v6h3l6 4V5L7 9z\"/><path d=\"M17 9a4 4 0 0 1 0 6\"/>", "permission": "ann.manage" }
  ],
  "permissions": ["ann.manage"]
}

migrations/001_create_announcements.sql:

CREATE TABLE ann_announcements (
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
  title TEXT NOT NULL,
  body TEXT NOT NULL,
  published BOOLEAN NOT NULL DEFAULT FALSE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

app/cactus-admin/announcements/list/page.tsx — admin page, reachable at /cacti/m/announcements/list:

import { getSessionFromCookie } from '@/lib/auth/session'
import { hasPermission } from '@/lib/permissions/check'
import { Pool } from 'pg'

const pool = new Pool({ connectionString: process.env.DATABASE_URL })

export default async function AnnouncementsPage() {
  const user = await getSessionFromCookie()
  if (!user || !await hasPermission(user, 'ann.manage')) {
    return <div>Access denied.</div>
  }

  const { rows } = await pool.query('SELECT * FROM ann_announcements ORDER BY created_at DESC')
  return (
    <div>
      <h1>Announcements</h1>
      <ul>
        {rows.map((r) => (
          <li key={r.id}>{r.title} - {r.published ? 'Published' : 'Draft'}</li>
        ))}
      </ul>
    </div>
  )
}

Admin pages live at app/cactus-admin/<module-name>/<page>/page.tsx inside the module repo. API routes live at app/api/admin/<module-name>/<endpoint>/route.ts. The build step runs generate-module-router.mjs which scans these directories and wires them into the core's catch-all routes automatically - no changes to core code required. The <module-name> wrapper is dropped from the URL only when it is the second segment (admin/<module-name>/..., public/<module-name>/...); a segment sharing the module's name anywhere else - admin/visitors/[id]/chat in the chat module, admin/boards/boards/... in boards - stays part of the path.

That's a complete, installable module. Tag it, release it, and any Cactus site can install it.


Wiki: Home · Getting started · Running locally · Architecture overview · Authoring a module · Authoring a theme · Self-hosting and operations

Clone this wiki locally