Plugin API Reference
The public plugin surface re-exported by @nextlyhq/plugin-sdk.
Alpha (
0.x) — pin your versions.@nextlyhq/plugin-sdkis the stability boundary — if it isn't exported there, it isn't public. Most of this surface is now@public(semver-protected); some is still@experimental. See API stability for the exact ledger.
Entry points
| Import | Surface | React? |
|---|---|---|
@nextlyhq/plugin-sdk | definePlugin, contributes/ctx/hook/event/filter types, PermissionSlug/EventName, secret() | no |
@nextlyhq/plugin-sdk/testing | createTestNextly | no |
@nextlyhq/plugin-sdk/client | useCan, <Can> | yes (optional peer) |
@nextlyhq/plugin-sdk/admin | registerComponent, registerComponents, registerKnownPlugin, ChallengeViewProps, ChallengeResolveResult | yes (optional peer) |
@nextlyhq/plugin-sdk/routing | slugToStaticParam | no |
@nextlyhq/plugin-sdk/db | Drizzle query operators (eq, and, inArray, …); sql is not among them | no |
@nextlyhq/plugin-sdk/widgets | WidgetComponentProps, WidgetSlot, WidgetResult, WidgetResultField, WidgetQueryBatchResponse — types only, no runtime | no |
definePlugin(def): PluginDefinition
interface PluginDefinition {
name: string;
version: string;
nextly: string; // core-compat range (boot-checked, may span majors)
dependsOn?: Record<string, string>; // required deps → version range
optionalDependsOn?: Record<string, string>; // enhance-if-present
enabled?: boolean; // default true; false = skip behavior, keep schema
contributes?: PluginContributions;
capabilities?: PluginCapabilities; // { net?: { outbound }, db?: { rawSql }, secrets?, auth?: { login } }
provides?: string[]; // capability names, e.g. "acme/auth-provider"
requires?: Record<string, string>; // capability → provider version range (checked, not ordering)
schemaVersion?: number;
setup?: (config: NextlyConfig) => NextlyConfig; // all setups before any init
init?: (ctx: PluginContext) => void | Promise<void>;
onReady?: (ctx: PluginContext) => void | Promise<void>; // after every init and route registration
destroy?: (ctx: PluginContext) => void | Promise<void>;
rename?: (map: Record<string, string>) => PluginDefinition; // integrator-side remap (D54)
}PluginContributions
interface PluginContributions {
collections?: CollectionConfig[];
singles?: SingleConfig[];
fieldGroups?: FieldGroupConfig[];
extend?: Array<{ target: string | string[]; fields: FieldConfig[] }>;
permissions?: PluginPermission[]; // { action, resource, label?, description?, group? }
fieldTypes?: PluginFieldType[]; // custom field types — see below
events?: Array<{ name: string }>; // each must begin with your slug and a dot, or boot fails
routes?: PluginRoute[]; // { method, path, handler, requiredPermission?, public?, middleware?, rateLimit?, rawBody?, csrf?, noStore?, formatTimestamps?, mount? }
auth?: PluginAuthContributions; // hooks, challenges, ui — see Auth extensibility
settings?: ZodObject; // gives ctx.settings
audit?: PluginAuditDeclaration; // gives ctx.audit
hookPoints?: PluginHookPointDeclaration[];
admin?: {
menu?: PluginMenuItem[]; // { label, to, icon?, order?, requiredPermission?, children? }
pages?: PluginAdminPage[]; // { path, component, requiredPermission? }
settings?: { component: ComponentPath };
views?: Record<string, PluginCollectionView>; // list/edit/before*/after* by collection slug
widgets?: PluginAdminWidget[]; // rendered on the dashboard; @experimental
};
}Component references are string paths: "<package>/<path>#<Export>", e.g. "@acme/x/admin#ReportsPage".
PluginContext (ctx)
interface PluginContext {
services: NextlyServices; // managed, secure-by-default; .collections supports { as: 'user' | 'system' }
db: PluginDatabase; // Drizzle builder + transaction; the live instance only with capabilities.db.rawSql
auth: PluginAuthApi; // always present; completeLogin needs capabilities.auth.login
settings?: PluginSettingsApi; // with contributes.settings
fetch?: typeof fetch; // with a non-empty capabilities.net.outbound
audit?: PluginAuditApi; // with contributes.audit
hooks: PluginHookRegistry; // in-transaction, modify/abort
events: EventBus; // post-commit, observe-only, best-effort
filters: PluginFilterRegistry; // transform values on typed seams (D63)
actions: PluginActionRegistry; // ordered side-effects on typed seams (D63)
self: { name: string; collections: Record<string,string>; singles: Record<string,string> }; // resolved slugs (D54)
config: Readonly<NextlyConfig>;
logger: Logger;
nextlyVersion: string;
}Secure-by-default services (D35)
// Acts as the current user (RBAC enforced):
await ctx.services.collections.listEntries(slug, {}, { as: "user", user: ctx.user });
// Privileged work — explicit, visible elevation:
const { item, warnings } = await ctx.services.collections.createEntry(slug, data, { as: "system" });Writes (createEntry, updateEntry, deleteEntry) resolve to the same
{ message, item, warnings? } envelope the Direct API and the REST API return.
warnings is present only when a post-commit hook failed: the row is already
saved at that point, so the write succeeds and the failure travels beside it.
Reads are unchanged and return their value directly.
Hooks & events
ctx.hooks.on(type, collectionSlug | "*", handler); // beforeCreate/afterCreate/… — can modify/abort
ctx.events.on(eventName, handler); // observe-only; e.g. `collection.${slug}.created`
ctx.events.emit(eventName, payload); // your own `<slug>.…` names, declared in contributes.eventsEvent-name constants are exported: DocumentEvents, AuthEvents, MediaEvents.
A plugin emits and declares only names beginning with its own slug and a dot, the slug
being pluginAdminSlug(name): @acme/billing emits acme-billing.charged.
ctx.events.emit throws FORBIDDEN for any other name, core's included, and a name in
contributes.events outside the slug fails boot (plugin-event-outside-namespace).
Typed slugs (codegen)
CollectionSlug, SingleSlug, PermissionSlug, and EventName are string by default and narrow to the installed unions once nextly generate:types runs. Author code stays valid either way.
Client (/client)
useCan(permission: string): boolean;
const Can: React.FC<{ permission: string; fallback?: ReactNode; children: ReactNode }>;Admin registration (/admin)
registerComponent(path, Component);
registerComponents({ [path]: Component });
registerKnownPlugin(packagePrefix, async () => { /* register */ });When you run nextly generate:types, a plugin-admin-imports.generated.ts is emitted that calls registerComponents for you — import it once in the app and your contributes.admin components load with no manual wiring.
Testing (/testing)
createTestNextly(opts: {
plugins: PluginDefinition[];
collections?: CollectionConfig[];
dialect?: "sqlite" | "postgresql" | "mysql"; // default sqlite (in-memory)
serverUrl?: string; // else TEST_POSTGRES_URL / TEST_MYSQL_URL
}): Promise<{ nextly; getService; hooks; events; adapter; destroy }>;
getConfiguredTestDialects(): TestDialect[]; // sqlite, plus any whose server URL is setCustom field types (contributes.fieldTypes)
A plugin can add a field type. It persists as one of the existing storage primitives — the plugin never invents DDL — and renders through its own admin component.
interface PluginFieldType {
type: string; // field.type value, e.g. "rating". Must not collide with a built-in
storage: FieldStoragePrimitive; // text | longText | boolean | number | timestamp | json
component: ComponentPath; // "<package>/<path>#<Export>"
label?: string; // picker label; defaults to a title-cased `type`
description?: string;
icon?: string; // Lucide icon name
category?: FieldTypeCategory; // picker grouping; defaults to "Advanced"
surfaces?: FieldSurface[]; // where it may be offered; defaults to entries/singles only
layout?: "takeover";
validate?: (value, args) => true | string | PluginFieldIssue[] | Promise<…>; // see below
validateOptions?: (field) => true | string | PluginFieldIssue[]; // see below
}validate — the type's own rules
Without it a custom type is only ever checked as its storage primitive: a
json-backed type would accept any JSON at all. Declare the rules on the type
and every instance gets them, rather than each schema author remembering to
repeat a field-level validate.
fieldTypes: [
{
type: "rating",
storage: "number",
component: "@acme/ratings/admin#RatingInput",
validate(value, { field }) {
// Already a number: the `number` primitive's rules ran first.
if (typeof value !== "number" || !Number.isInteger(value)) {
return "Rating must be a whole number";
}
const max = typeof field.max === "number" ? field.max : 5;
if (value > max) return `Rating cannot exceed ${max}`;
return true;
},
},
]args carries:
| Key | What it is |
|---|---|
data | The write payload, for rules spanning fields. On update this is the patch, not the merged stored entry. Always the top-level payload, even for a field nested in a repeater row. |
req | Request context; carries user when authenticated. Empty for a field inside a field group instance. |
field | The instance, so you can read the options your type declares. A detached copy: records, arrays, dates, sets and maps are rebuilt, so editing them changes nothing. Functions and class instances stay shared — treat it as read-only. |
path | Where this field sits in the write ("stars", "rows[1].stars"). |
mode | "create" or "update". |
Return true to accept, a string for one problem, or an array when one value
can be wrong in several places at once. Issue paths are used as given, so build
them from args.path or they will be wrong for a nested instance:
return [
{ path: `${path}.nodes[2]`, code: "DISALLOWED", message: "That block is not allowed here" },
{ message: "Document exceeds the node limit" }, // path/code default to the field's own
];Ordering: the built-in rules for your storage primitive run first, then this,
then the field's own validate — so a schema author adds rules on top of yours
rather than replacing them. Two values never reach validate: an absent one
(that is what required is for) and one the storage primitive already refused,
so you never have to re-check that you were handed the shape your type stores.
For json that check is representability — a cycle or a BigInt is rejected
before you see it — and nothing more, so the shape of the document is yours to
define.
Anything you return outside true | string | PluginFieldIssue[] is a refusal,
as is throwing — a validator that forgets to return fails the write rather than
silently accepting everything.
Where it runs. The entry, single, and field group write paths. A type offered
on the users, forms, or blocks surface does not run it yet: those
surfaces validate through their own paths. A disabled plugin keeps its field
types registered (its collections are retained) but its validate does not
run — that is behavior, and a disabled plugin contributes none.
validateOptions — rules about the declaration
validate answers "is this value allowed in this field". validateOptions
answers "is this field declared coherently at all". It runs when a schema is
registered, not when a value is written.
The distinction matters because a schema defect reported per write is reported to the wrong person: the writer cannot fix it, and it fails every write until whoever declared the field notices.
validateOptions(field) {
const policy = field.policy;
if (policy === undefined) return true;
if (policy === null || typeof policy !== "object") {
return [{ path: "policy", message: "policy must be an object" }];
}
const { kinds } = policy as { kinds?: unknown };
if (Array.isArray(kinds) && kinds.length === 0) {
// Nothing could ever be stored in this field. That is a contradiction in
// the declaration, not a problem with any particular value.
return [{ path: "policy.kinds", message: "policy.kinds cannot be empty" }];
}
return true;
}Synchronous on purpose: a declaration is checked against itself, and a
config-time rule that needed I/O would make startup depend on something that can
be down. Issue paths are relative and get appended to the field's own, so
"policy.kinds" reports against fields[2].policy.kinds — unlike validate,
whose paths are absolute, because this one only ever names an option it already
knows and has no way to learn its own index. Throwing is a rejected declaration,
not a crash. A returned code is not carried: config errors report through
closed, public code unions, so the canonical member is used and your message
carries the detail.
Where it runs. Every path a declaration reaches storage by:
| Path | When |
|---|---|
| boot | over the resolved config and a plugin's own raw contributions, once the registry is populated |
db:sync and its watcher | before anything is serialized or materialized |
| Schema Builder writes | before the payload is stored, and again before the manifest is written |
| direct collection/single/field-group create and update | before the definition is persisted and its DDL runs |
nextly build | over collections, singles and field groups |
migrate:create | over the merged set the migration is generated from |
| HMR reload | after the reload repopulates the registry |
Not the define* calls, despite their being where a code-first config is
validated. The config bundle is evaluated before contributes.fieldTypes is
registered, so a custom type is not yet known at that point and the define call
rejects it as an unknown field type before any option check could run. Do not
reach for defineCollection to get your declaration checked — that ordering is
why every gate above sits after registration.
Your check always sees the declaration as written. On the Builder path that is the submitted payload, not the parsed copy: the manifest schema drops keys it does not declare, while the write persists the original, so your options are present in what gets stored and absent from what was parsed.
A disabled plugin runs neither hook. Its field types stay registered, because its
collections are retained and their fields still have to map to a column and
render, but validate and validateOptions are both dropped at registration —
they are behavior, and a disabled plugin contributes none.
Where your options live. An option written directly on the field is read as
written, and that is the ergonomic choice while its name differs from every key
the field schema declares — options, fields, admin, label and the rest of
the built-in field surface. The manifest applies the built-in shape to every
field whatever its type, so a name that does collide is judged against the core
meaning (options as a select's choice array) and rejected before your check
runs.
For a name core already uses, or to be safe against names core may add later,
put the option in the pluginOptions container. Core never looks inside it, so
any name is legal there:
{
"name": "rating",
"type": "star-rating",
"pluginOptions": { "options": { "presets": ["a", "b"] } }
}Both are read, and your type is handed one flat view either way, so
field.options above resolves to your object without your having to know where
it was stored. The container wins when the same name appears in both. Writes
from the Schema Builder go to the container, so a field stored the older way
moves there the next time it is saved; nothing you read changes when it does.
Two names are reserved inside the container: type and name. The instance
you are handed states which field it is under those keys, restated after your
options are folded in, so an option using either would be shadowed and never
reach you. A manifest write declaring one is refused rather than silently
losing it.
Fields nested in a repeater or group are covered. A type offered only on the
users, forms, or blocks surface is not: those surfaces have their own
config validators, which do not consult this registry.
Declaring one on the users surface from code. UserFieldConfig is a union
of the built-in field shapes, so a select missing its options fails on its
own shape. A plugin type's token is not knowable there, and an arm open enough
to accept it would accept that malformed select too and lose the error. Wrap
the declaration in pluginUserField() instead, which marks it as belonging to
the open arm:
import { defineConfig, pluginUserField } from "nextly";
export default defineConfig({
users: {
fields: [
{ name: "company", type: "text" },
pluginUserField({ name: "score", type: "star-rating" }),
],
},
});Options are optional there: pass pluginOptions, write them directly on the
field, or pass none at all. Note that these options reach type generation but
are not persisted, so a field read back from the database after boot carries
none of them.
Secrets
secret(value): Secret<T>; // auto-redacts in logs / JSON / inspect; .reveal() to read
isSecret(v): boolean;See the author guide for the full workflow and the error reference for boot-error meanings.
Runtime surfaces added for stateful plugins
ctx.settings is present only when the plugin declares
contributes.settings, ctx.fetch only with a non-empty
capabilities.net.outbound, and ctx.audit only with contributes.audit, so
a plugin that never asked for one does not have it. They are typed as optional
on PluginContext; read them through a guard that names the missing
declaration. ctx.auth and ctx.filters are always present.
ctx.settings
Declare contributes.settings (a zod object) to get it.
if (!ctx.settings) throw new Error("declare contributes.settings");
const config = (await ctx.settings.get()) as MySettings;
await ctx.settings.set({ apiKey: "..." }, { actorUserId: user.id });Values are parsed by the declared schema, and the paths named in
capabilities.secrets are encrypted at rest. See
security for what is and is not returned to the admin.
set is a JSON merge patch (RFC 7396):
keys you leave out keep their stored value at every depth, and a key set to
null is removed, so set({ providers: { github: null } }) deletes one entry
of a record. The result is validated as a whole, and a key the schema does not
declare is refused at any depth. One key's stored value may be at most 256 KiB.
get throws an internal error when the stored settings no longer fit the
schema — for example after an update added a required key — or hold a secret
nothing can decrypt; the log names the plugin and the paths, and the admin
settings page still opens and shows what is missing.
After any change, from the admin or from ctx.settings.set, core emits
plugin.settings.changed with { plugin, changedKeys } (key names, never
values), and an operator's change is recorded in the activity log by key name.
ctx.fetch
Declare a non-empty capabilities.net.outbound to get it.
if (!ctx.fetch) throw new Error("declare capabilities.net.outbound");
const res = await ctx.fetch("https://accounts.google.com/.well-known/openid-configuration");Reaches only the declared hosts, and only addresses outside your own network.
ctx.audit
Declare contributes.audit to get it.
if (!ctx.audit) throw new Error("declare contributes.audit");
await ctx.audit.write({
kind: "acme-auth.identity-linked",
actorUserId: user.id,
metadata: { provider: "google" },
});A kind is the plugin's own admin slug, a ., a lowercase letter or digit, then
any of lowercase letters, digits, ., _ and -; a kind outside that, longer
than the audit column, or declared twice fails boot. Undeclared kinds and keys
are dropped rather than stored. targetUserId must name an existing user; a
row naming one that does not exist is stored without its metadata, and a
warning is logged.
ctx.auth
Always present. completeLogin requires capabilities.auth.login and a
strategy name beginning with the plugin's own slug.
return ctx.auth.completeLogin(user.id, {
request,
strategy: "acme-google-auth:google",
next: "/admin",
});Also ctx.auth.currentUser(request) and ctx.auth.verifyCsrf(request). currentUser answers null to a POST, PUT, PATCH or DELETE whose Origin (or, without one, Referer) is neither your site nor an allowed origin, or that names no origin, as it does to a request with no session. verifyCsrf accepts the token in the x-csrf-token header or as csrfToken in a JSON body; it reads at most 64 KB of a body looking for it, and refuses a larger one with reason: "body-too-large", so a large request sends the token in the header.
ctx.filters.decide
For a seam where handlers VETO rather than transform.
const verdict = await ctx.filters.decide(
"acme-auth.may-link",
{ allow: true },
{ userId }
);A handler that throws denies, and a handler may only keep or downgrade the verdict — so the order plugins load in cannot decide access.
Typed hook points
ctx.filters and ctx.actions take their types from HookPointPayloads
(@experimental), keyed by the point's name. Core's seams (email.beforeSend,
email.afterSend, admin.nav, collections.listQuery) are listed; a plugin
lists the points it publishes by augmenting the interface:
declare module "@nextlyhq/plugin-sdk" {
interface HookPointPayloads {
"acme-auth.profile": {
kind: "filter";
value: { displayName: string };
context: { userId: string };
};
"acme-auth.may-link": { kind: "decision"; context: { email: string } };
"acme-auth.linked": { kind: "action"; payload: { userId: string } };
}
}A listed point's handlers and calls are then checked against its entry, and one
used through the wrong registry (a decision through apply, say) does not
compile. A name with no entry is typed by the call site, as before.
Lifecycle
onReady runs after every plugin has initialised and routes are registered.
There are no install or uninstall hooks yet: they arrive with the install
command that calls them. Do one-time setup in init, written so running it on
every boot is harmless.