Building Plugins
Package your own admin pages and tRPC procedures as a plugin that any nextjs-cms project can install.
A plugin is an npm package that contributes:
- Routes: one or more pages in the admin sidebar.
- A tRPC router (optional): server procedures your pages call, protected by the admin's privileges.
- Server components: what renders on each page.
This guide builds a small "Contact messages" plugin that lists messages from a contact_messages table.
Package layout
plugin-contact-messages/
├── package.json ← name: @nextjscms/plugin-contact-messages
└── src/
├── define.ts ← the descriptor: title, routes
├── index.ts ← exports the descriptor and createPlugin()
├── router.ts ← tRPC procedures
├── server.tsx ← server entry (page component)
└── client/
└── MessagesPage.tsxThe package must be named @nextjscms/plugin-<slug> with a lowercase kebab-case slug. The name drives the plugin's internal name (plugin_contact_messages) and its tRPC router key (contactMessages), and the loader rejects anything else.
1. The descriptor
import { definePlugin } from 'nextjs-cms/plugins/define'
export const contactMessagesPlugin = definePlugin({
package: '@nextjscms/plugin-contact-messages',
title: { en: 'Messages', ar: 'الرسائل' },
routes: [
{
path: '/contact-messages', // mounted exactly here in the admin
title: { en: 'Messages', ar: 'الرسائل' },
icon: 'inbox',
prefetch: 'contactMessages.list', // tRPC paths to prefetch on the server
},
],
})| Field | Description |
|---|---|
package | The npm package name. |
title | Plugin title (string or per-language object). Used for the sidebar group and the privileges list. |
icon | Sidebar group icon for multi-page plugins. |
routes[].path | Admin URL of the page. Must be unique across plugins. |
routes[].title / icon | Sidebar label and icon of the page. |
routes[].component | Named export in server.tsx that renders the page. Required when the plugin has two or more routes; a single-route plugin uses the default export. |
routes[].prefetch | One or more tRPC dot-paths prefetched on the server and hydrated into React Query before the page renders. |
The descriptor has no server code, so cms.config.ts can import it cheaply.
2. The router
import { sql } from 'drizzle-orm'
import { pluginProcedure, router } from 'nextjs-cms/api/plugin'
import { db } from 'nextjs-cms/db/client'
import { derivePluginName } from 'nextjs-cms/plugins/derive'
import * as z from 'zod'
import { contactMessagesPlugin } from './define.js'
const PLUGIN_NAME = derivePluginName(contactMessagesPlugin.package) // 'plugin_contact_messages'
export const contactMessagesRouter = router({
// Any admin with access to the plugin
list: pluginProcedure(PLUGIN_NAME).query(async () => {
const [rows] = await db.execute(sql`SELECT * FROM contact_messages ORDER BY created_at DESC LIMIT 100`)
return rows as unknown as { id: number; name: string; email: string; message: string }[]
}),
// Requires the D (delete) operation on the plugin
remove: pluginProcedure(PLUGIN_NAME, 'D')
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
await db.execute(sql`DELETE FROM contact_messages WHERE id = ${input.id}`)
return { ok: true }
}),
})Helper (from nextjs-cms/api/plugin) | Access |
|---|---|
publicProcedure | Anyone, even signed out. Rarely what you want. |
privateProcedure | Any signed-in admin. |
pluginProcedure(name, operation?) | Admins with a privilege on the plugin. Pass 'C', 'U', or 'D' to also require that operation. |
3. The entry point
import { contactMessagesPlugin } from './define.js'
export { contactMessagesPlugin }
// Called by the CMS plugin loader with `options` from cms.config.ts.
export async function createPlugin(options?: Record<string, unknown>) {
// Lazy-import the router so importing the descriptor stays cheap.
const { contactMessagesRouter } = await import('./router.js')
return { ...contactMessagesPlugin, router: contactMessagesRouter }
}The router is mounted at contactMessages (the slug in camelCase), so its procedures are contactMessages.list and contactMessages.remove. If that key collides with a core or app router, the CMS refuses to build the router and reports the conflict.
4. The page
import { MessagesPage } from './client/MessagesPage.js'
// Single-route plugin: the default export renders the page.
// The host already ran `prefetch` and wraps this in <HydrateClient>.
export default async function ContactMessagesServer() {
return <MessagesPage />
}'use client'
import { usePluginTRPC } from 'nextjs-cms/plugins/client'
type Api = {
contactMessages: {
list: { useQuery: () => { data?: { id: number; name: string; email: string; message: string }[] } }
}
}
export function MessagesPage() {
const trpc = usePluginTRPC<Api>()
const { data } = trpc.contactMessages.list.useQuery()
return (
<div className='space-y-3 p-6'>
{data?.map((m) => (
<div key={m.id} className='rounded-lg border p-4'>
<p className='font-medium'>
{m.name} <{m.email}>
</p>
<p className='text-sm opacity-80'>{m.message}</p>
</div>
))}
</div>
)
}usePluginTRPC() returns the host app's tRPC React client, so your plugin shares its query cache and auth.
5. Multi-page plugins
export const shopToolsPlugin = definePlugin({
package: '@nextjscms/plugin-shop-tools',
title: 'Shop tools',
icon: 'store',
routes: [
{ path: '/shop/orders', title: 'Orders', icon: 'receipt', component: 'OrdersPage', prefetch: 'shopTools.orders' },
{ path: '/shop/coupons', title: 'Coupons', icon: 'ticket', component: 'CouponsPage' },
],
})export async function OrdersPage() { /* ... */ }
export async function CouponsPage() { /* ... */ }Both pages appear under a collapsible "Shop tools" group (unless the host sets groupRoutes: false).
6. Options
Read options in createPlugin and keep them in module state:
type Options = { pageSize?: number }
let options: Required<Options> = { pageSize: 50 }
export const getOptions = () => options
export async function createPlugin(raw?: Record<string, unknown>) {
options = { pageSize: Number((raw as Options | undefined)?.pageSize ?? 50) }
const { contactMessagesRouter } = await import('./router.js')
return { ...contactMessagesPlugin, router: contactMessagesRouter }
}7. Ship it
In package.json, export the entries the host imports:
{
"name": "@nextjscms/plugin-contact-messages",
"type": "module",
"exports": {
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"./server": { "types": "./dist/server.d.ts", "default": "./dist/server.js" }
},
"peerDependencies": { "nextjs-cms": "*", "react": "*" }
}Host projects then follow Using Plugins: install, add to plugins, register plugin_contact_messages: '@nextjscms/plugin-contact-messages/server'.
Translations
Ship dictionaries from a ./translations export and ask users to merge them into i18n.supportedLanguages, as the official plugins do. In components, read strings with useI18n() from nextjs-cms/translations/client.
Checklist
- Package named
@nextjscms/plugin-<slug> -
definePlugindescriptor exported from the package root -
createPlugin()lazy-imports the router - Procedures use
pluginProcedure(PLUGIN_NAME, op?) -
./serverexport with a default component (single route) or named components (multi-route) - README lists env vars, options, and the registry line