Plugin Loader
How plugin packages are resolved, instantiated, merged into the tRPC router, and rendered as admin pages.
Code: packages/nextjs-cms/src/plugins/
| File | Role |
|---|---|
types.ts | CMSPlugin, descriptors, routes; single-route vs. multi-route shapes |
define.ts | definePlugin(), typed identity helper |
derive.ts | derivePluginName(): @nextjscms/plugin-foo-bar → plugin_foo_bar |
loader.ts | loadPlugins(), routes, navigation, dashboard override |
server.ts | Server helpers for plugin components (findPluginRouteByPath) |
client.tsx | PluginTRPCProvider, usePluginTRPC() |
prefetch.ts | Route prefetch execution |
blank.tsx | Placeholder component for the empty registry |
Descriptor vs. plugin
- A descriptor (
definePlugin({ package, title, routes })) is static data.cms.config.tsimports it, so it must not import server code. - A plugin is what
createPlugin(options)returns: the descriptor plusrouter(and optionallyinit). 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.
Navigation
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.