Manifest reference
Every block module.json can carry. Only the first five are required.
Identity
{
"id": "my-module",
"name": "My Module",
"description": "One line, shown in the marketplace and the catalogue.",
"version": "1.0.0",
"coreVersion": "^2.1.0",
"category": "content",
"icon": "Package",
"author": "Your name"
}
id must match the directory name and must be a lowercase slug. coreVersion
is a semver range over the core's module-facing API version, and the platform
refuses to install or enable a module whose range the running core does not
satisfy.
Pages and screens
{
"routes": [
{ "path": "notices", "component": "pages/page.tsx", "titleKey": "notices.pageTitle" },
{ "path": "notices/[id]", "component": "pages/[id]/page.tsx", "resolver": "lib/notice-exists.ts" }
],
"adminRoutes": [
{ "path": "/notices", "component": "pages/admin/page.tsx" }
],
"menu": [
{ "label": "Notices", "path": "/notices", "icon": "Megaphone", "group": "content", "section": "publishing" }
]
}
A dynamic public route needs a resolver: a file exporting a function that
says whether the thing at that address exists. Without one the page renders
"nothing here" with a 200, which tells a crawler the page is real.
menu.group is which admin group the screen belongs to, and menu.section
which section inside it. A module that declares neither lands in a catch-all
drawer, which is a page an operator finds by accident or not at all.
Endpoints
{
"api": [
{ "path": "notices", "handler": "api/route.ts", "method": "ALL",
"rateLimit": { "maxRequests": 60, "windowMs": 60000 } },
{ "path": "webhook/acme", "handler": "api/webhook/route.ts", "providerCallback": true }
]
}
Mutating verbs are CSRF-checked by the proxy. A provider that calls back into
your module cannot carry a CSRF token, so it declares providerCallback and
takes a literal first path segment instead.
Permissions and data
{
"permissions": ["notices.view", "notices.manage"],
"permissionResources": [{ "type": "notice", "label": "Notice" }],
"userDataExport": ["Notice"]
}
Permissions appear in the role editor when the module is installed and leave it
when the module is uninstalled. userDataExport names the tables that belong
to a person, so a data export request answers correctly without anyone
remembering this module exists.
Extending other things
{
"hooksEmitted": [{ "name": "notice.published", "description": "After a notice goes live" }],
"hookListeners": [{ "hook": "user.registered", "handler": "lib/on-register.ts" }],
"slots": [{ "name": "notices.aboveList" }],
"slotContents": [{ "slot": "layout.top", "component": "components/Banner.tsx" }],
"pageBlocks": [{ "id": "notice-list", "component": "components/blocks/List.tsx", "category": "content" }],
"cronJobs": [{ "name": "expire-notices", "schedule": "0 * * * *", "handler": "lib/expire.ts" }]
}
A hook a module emits is typed: core generates the payload types from these
declarations, so a listener that reads a field the hook does not send fails the
type check rather than reading undefined in production.
Strings
{
"translations": {
"en": { "notices": { "pageTitle": "Notices" } },
"tr": { "notices": { "pageTitle": "Duyurular" } }
}
}
Every locale the platform ships, every time. They are synced into the translation table on install, and an administrator's edits to them survive both an update and an uninstall.
What the validator refuses
- An
idthat does not match the directory, or that is a reserved word - An admin path that includes
/admin(core adds the prefix) - A file reference that points outside the module directory
- An import of
@/core/lib/*instead of the SDK - A dynamic public route with no resolver
- A dependency range no installed module can satisfy
- A hook listener whose payload does not match what the hook declares