nextjs-cms

Plugin Loader

How plugin packages are resolved, instantiated, merged into the tRPC router, and rendered as admin pages.

Code: packages/nextjs-cms/src/plugins/

FileRole
types.tsCMSPlugin, descriptors, routes; single-route vs. multi-route shapes
define.tsdefinePlugin(), typed identity helper
derive.tsderivePluginName(): @nextjscms/plugin-foo-bar → plugin_foo_bar
loader.tsloadPlugins(), routes, navigation, dashboard override
server.tsServer helpers for plugin components (findPluginRouteByPath)
client.tsxPluginTRPCProvider, usePluginTRPC()
prefetch.tsRoute prefetch execution
blank.tsxPlaceholder component for the empty registry

Descriptor vs. plugin

  • A descriptor (definePlugin({ package, title, routes })) is static data. cms.config.ts imports it, so it must not import server code.
  • A plugin is what createPlugin(options) returns: the descriptor plus router (and optionally init). The loader calls it lazily on the server.

defineConfig normalizes each plugins entry to a runtime entry { package, options?, override?, groupRoutes? }.

loadPlugins()

for each config.plugins entry:
  ├─ skip duplicates (same package twice)
  ├─ loadPluginModule(package)          jiti import, resolved from the app's node_modules
  ├─ resolvePluginInstance(module)      createPlugin(options) | module.plugin | default export
  ├─ name     = derivePluginName(package)
  ├─ routerKey= derivePluginRouterKey(package)   foo-bar → fooBar
  ├─ apply override.title / icon / routes[path]  (warn about no-op overrides)
  └─ plugin.init?.({ config: entry })

Results are cached and invalidated with the config version, like everything else derived from cms.config.ts.

Why jiti: plugin packages are loaded at runtime from the host app's dependencies, outside the Next.js bundle graph, the same way section files are. Server-only modules inside plugins resolve to an empty stub where needed (resolveServerOnlyEmptyPath).

Routers

createTRPCRouter() merges { ...coreRouters, ...pluginRouters, ...userRouters }. A plugin router whose key collides with a core or app router throws. Procedures use pluginProcedure(name, op?) (exported from nextjs-cms/api/plugin), which checks admin_privileges for the plugin's name.

Privileges

Each plugin appears in the privileges list under its name (plugin_foo_bar) with its title. getPluginRoutes() filters routes by the admin's privileges; the plugins.checkRoute procedure uses it to authorize a URL. The dashboard-override plugin skips the privilege check so every admin can see the dashboard.

Rendering

/(rootLayout)/(plugins)/[...slug]/page.tsx
  ├─ plugins.checkRoute({ path })           authorized route or 404
  ├─ run route.prefetch dot-paths           api.<path>.prefetch()
  ├─ getPluginServerComponent(name, component)
  │     └─ pluginNamesMap[name] → import('@nextjscms/plugin-x/server')[component ?? 'default']
  └─ <HydrateClient><Component /></HydrateClient>

plugin-server-registry.ts in the app is a static map because Next.js must see each import() target at build time. That's why installing a plugin requires one registry line.

getPluginNavigation() returns leaves (single-route plugins, or multi-route plugins with groupRoutes: false) and groups (multi-route plugins, default), with overrides applied. The sidebar merges them with sections.

Changing the plugin contract

The descriptor and CMSPlugin types are a public API for third-party plugins. Additive, optional fields are fine; anything else is breaking and needs a major version and a migration note for plugin authors in Building Plugins.