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

Plugins

Plugin Author Guide

Build a Nextly plugin — the lifecycle, the plugin context, and the scaffold → dev → test → publish loop.

Alpha (0.x) — pin your versions.

The plugin API surface is now stable and semver-protected (see API stability); the packages themselves are still 0.x alpha. Build against @nextlyhq/plugin-sdk — it is the stability boundary — and pin your nextly / @nextlyhq/plugin-sdk versions.

A plugin extends Nextly with new collections, hooks, events, routes, permissions, and admin UI — without modifying core. This guide is the overview; deeper topics each have their own page:

Scaffold a plugin

npm create nextly-app -- --template plugin

This generates a publishable package whose plugin code lives in src/ plus an embedded dev/ playground — a minimal Nextly app on SQLite with hot-reload that registers your plugin so you can exercise it in a real admin. dev/ is never published (only dist/ ships).

my-plugin/
├── src/                 # your plugin (published as dist/)
│   ├── index.ts
│   ├── plugin.ts        # definePlugin(...)
│   ├── collections/
│   └── admin/
├── dev/                 # local playground — NOT published
│   ├── nextly.config.ts # registers your plugin
│   ├── next.config.ts   # source-mode HMR for src/
│   └── instrumentation.ts
└── package.json         # files: ["dist"]; nextly-plugin keyword

The plugin object

Author with definePlugin from @nextlyhq/plugin-sdk:

import { definePlugin } from "@nextlyhq/plugin-sdk";

type MyPluginOptions = { greeting?: string };

export const myPlugin = (opts: MyPluginOptions = {}) =>
  definePlugin({
    name: "@acme/nextly-plugin-greetings",
    version: "0.1.0",
    nextly: ">=0.0.2-alpha.63", // core-compatibility range, boot-checked
    dependsOn: {}, // required plugin deps → version range
    enabled: true, // false skips behavior but keeps schema (D49)
    contributes: {
      /* declarative nouns — see below */
    },
    setup(config) {
      return config;
    }, // escape hatch — all setups run before any init
    init(ctx) {
      /* runtime wiring */
    },
    destroy(ctx) {
      /* teardown on shutdown / HMR / test */
    },
  });

Choosing the nextly range

The floor names the release that introduced the newest core API your plugin actually calls — not the version you happened to develop against, and not the latest release.

Get it too low and the failure is confusing: your plugin boots against a core that lacks the API, the import resolves to nothing, and the crash names a symbol rather than a version. Too high, and installs that would have worked are refused.

There is one case that catches people out. If the API you need is landing in the same release as your plugin — you added it to core yourself, or you are building inside the Nextly monorepo — then the version to name does not exist yet. Writing the next one is a guess about a release that has not happened, and it makes your plugin unusable in the source tree, where core carries the current version: every boot fails compatibility before a single test runs.

Where your package and core version together, say so directly instead:

import { version } from "../package.json";

definePlugin({
  name: "@acme/nextly-plugin-greetings",
  version: "0.1.0",
  // Released in lockstep with core, so a core at least as new as this build is
  // exactly the compatibility that is meant — and it stays true after every
  // release without being edited.
  nextly: `>=${version}`,
  // …
});

That only applies where the two genuinely ship together. A plugin released on its own schedule should name the real floor, and leave it alone until it calls something newer.

contributes (declarative)

contributes is data the host can read without running your plugin (so the admin and codegen can reason about it):

  • collections / singles / components — new, plugin-owned schema entities.
  • extend — add fields to existing entities by slug.
  • permissions — custom (non-CRUD) permissions; CRUD is auto-seeded per collection/single.
  • events — custom event names you may emit.
  • routes — HTTP endpoints, namespaced under /plugins/<name>/… beneath wherever your app mounts the Nextly handler (/admin/api in a scaffolded project), secure by default.
  • admin — menu, pages, settings, and per-collection view overrides (referenced by string component path).

Lifecycle

contributes → setup → init → destroy. All plugins' setups run before any init. Load order is a topological sort over dependsOn (array order breaks ties). A missing/incompatible/cyclic dependency is a fail-fast boot error (see the error reference).

The plugin context (ctx)

init(ctx) and destroy(ctx) receive:

FieldUse
ctx.servicesManaged data access — secure by default (acts as the ambient user, RBAC on). Pass { as: "system" } to elevate. Validation/hooks/events always run.
ctx.dbRaw Drizzle escape hatch — unmanaged (you own consistency + portability).
ctx.hooksIn-transaction hooks — can modify or abort an operation.
ctx.eventsPost-commit event bus — observe-only, best-effort (may be dropped). React/notify here.
ctx.filters / ctx.actionsTyped seams to transform values / run ordered side-effects.
ctx.selfYour entities' resolved slugs after any host .rename(). Always reference your own entities through this.
ctx.loggerStructured logging.
ctx.configRead-only Nextly config.
ctx.nextlyVersionThe running core version (feature detection).

Hooks vs events: need to change or block an operation → use a hook; need to react after it commits → use an event.

Never hardcode your own slugs in init. Use ctx.self so your plugin keeps working when an integrator renames an entity:

init(ctx) {
  ctx.hooks.on("afterCreate", ctx.self.collections.submissions, sendEmail);
  ctx.events.on(`collection.${ctx.self.collections.submissions}.created`, notify);
}

The dev loop

pnpm install
pnpm dev          # runs the dev/ playground (next dev) → open /admin

Editing files under src/ hot-reloads the playground (the dev/next.config.ts transpiles your plugin source). Your plugin's destroy() runs on each reload, so clean up subscriptions there.

HMR gotcha: Nextly's registries (hooks, events, filters) survive hot-reload because they live on globalThis. If you keep state in a module-scoped singleton, it will be recreated on every reload and your subscriptions lost. Register through ctx.hooks/ctx.events/ctx.filters instead.

Test

import { createTestNextly } from "@nextlyhq/plugin-sdk/testing";
import { myPlugin } from "../src";

const t = await createTestNextly({
  plugins: [myPlugin()],
  collections: myPlugin().contributes.collections,
});
// drive t.nextly / assert t.hooks, t.events, inspect t.adapter …
await t.destroy();

createTestNextly boots a real Nextly on in-memory SQLite and runs your full lifecycle.

To check your plugin against a real database, pass dialect. Each boot gets its own database, created and dropped by destroy(), so runs never contaminate each other:

import {
  createTestNextly,
  getConfiguredTestDialects,
} from "@nextlyhq/plugin-sdk/testing";

// "sqlite" is always included; the others are included when TEST_POSTGRES_URL /
// TEST_MYSQL_URL point at a server you are willing to create databases on.
// Configured, not probed: a URL you set but cannot reach fails rather than
// skipping, which is usually what you want from a broken environment.
for (const dialect of getConfiguredTestDialects()) {
  describe(`myPlugin (${dialect})`, () => {
    it("works", async () => {
      const t = await createTestNextly({ dialect, plugins: [myPlugin()] });
      // …
      await t.destroy();
    });
  });
}

Worth doing if your plugin contributes collections or touches the database directly: column types, default expressions, and JSON handling differ between dialects in ways SQLite alone will not reveal.

Type safety (codegen)

Run nextly generate:types in the consuming app to narrow CollectionSlug, PermissionSlug, and EventName to the real, installed values — including your plugin's. When a plugin contributes admin components, the same command emits a plugin-admin-imports.generated.ts import map so those React components load with no manual host imports.

Publish

Build (tsup), publish to npm, and add the nextly-plugin keyword to package.json so it's discoverable. Declare an honest nextly range. See the API reference for the full public surface and the error reference for boot-error meanings.

Stability

The public surface (definePlugin/contributes/lifecycle, ctx.services, contributes.routes, contributes.admin, ctx.events + event names, HookContext, and @nextlyhq/plugin-sdk/testing) is now @public — semver-protected, so breaking it requires a Nextly major. Some surfaces are still @experimental (the raw ctx.db, ctx.hooks registration, filters/actions, secret(), useCan/<Can>, admin widgets) and may change. The full ledger is on the API stability page. Pin versions while we are pre-1.0.