Skip to content
Documentation

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 id that 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