You're reading docs for Nextly Alpha. APIs may change between releases.

Plugins

Plugin API Reference

The public plugin surface re-exported by @nextlyhq/plugin-sdk.

Alpha (0.x) — pin your versions. @nextlyhq/plugin-sdk is 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

ImportSurfaceReact?
@nextlyhq/plugin-sdkdefinePlugin, contributes/ctx/hook/event/filter types, PermissionSlug/EventName, secret()no
@nextlyhq/plugin-sdk/testingcreateTestNextlyno
@nextlyhq/plugin-sdk/clientuseCan, <Can>yes (optional peer)
@nextlyhq/plugin-sdk/adminregisterComponent, registerComponents, registerKnownPlugin, ChallengeViewProps, ChallengeResolveResultyes (optional peer)
@nextlyhq/plugin-sdk/routingslugToStaticParamno
@nextlyhq/plugin-sdk/dbDrizzle query operators (eq, and, inArray, …); sql is not among themno
@nextlyhq/plugin-sdk/widgetsWidgetComponentProps, WidgetSlot, WidgetResult, WidgetResultField, WidgetQueryBatchResponse — types only, no runtimeno

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.events

Event-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 set

Custom 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:

KeyWhat it is
dataThe 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.
reqRequest context; carries user when authenticated. Empty for a field inside a field group instance.
fieldThe 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.
pathWhere 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:

PathWhen
bootover the resolved config and a plugin's own raw contributions, once the registry is populated
db:sync and its watcherbefore anything is serialized or materialized
Schema Builder writesbefore the payload is stored, and again before the manifest is written
direct collection/single/field-group create and updatebefore the definition is persisted and its DDL runs
nextly buildover collections, singles and field groups
migrate:createover the merged set the migration is generated from
HMR reloadafter 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.