Skip to content
Documentation

Writing a module

A module is a directory with a manifest.

my-module/
  module.json          what this module adds
  pages/               public pages
  pages/admin/         admin screens
  api/                 endpoints under /api/v1/
  components/          React components
  lib/                 this module's own code
  schema.prisma        this module's own tables
  migrations/          numbered SQL, forward only
  seed.ts              optional demo data

Nothing here is discovered by scanning. The manifest names every page, every endpoint and every screen, which is what lets the platform tell an operator exactly what a module will do before they install it.

Reaching the core

Import through the SDK, which is split by where the code runs:

| Import | For | | --- | --- | | @/core/sdk | Anything that runs in both places | | @/core/sdk/server | The database, the filesystem, request headers | | @/core/sdk/ui | Components, hooks, the pager, dialogs | | @/core/sdk/auth | The session and permissions | | @/core/sdk/navigation | Locale-aware links and redirects | | @/core/sdk/layout | The page frame every public page is built in |

Reaching past the SDK into the core's internals is refused three times over: by the module validator, by the marketplace build, and by the linter. That is the whole reason an upgrade of the platform cannot quietly break a module - the surface a module is allowed to touch is the surface that carries a version number.

The split by runtime is not bureaucracy either. @/core/sdk must stay importable from a browser bundle, so anything touching the database or a Node builtin lives in /server, and a client component that imports the wrong one fails the build rather than shipping the database client to a visitor.

Rules worth knowing before the first commit

Every user-facing string is translated. useTranslations() on the client, getTranslations() on the server, and the strings themselves declared in the manifest's translations block in every locale.

Every external input is validated. A query parameter, a request body, a webhook payload. The platform ships Zod and uses it at every boundary.

Every list that can grow is paged. A module that renders whatever the endpoint happened to return has a bug that looks exactly like a short list.

A hook that throws must not take a page down. Module hooks run inside an error boundary; write them as if the boundary is not there and let it catch you anyway.

Versions

Declare coreVersion as a range. Bump your own version when you ship: a patch for a fix, a minor for an addition, a major when something a dependent module relied on changed shape.

Checking it

npm run validate:module -- my-module
npm run typecheck:modules

The first checks the manifest, the SDK boundary, the file references and the route declarations. The second type-checks your module against the merged database client, which is the only way to catch a query against a table another module owns.