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.