nextjs-cms

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.tsx
Warning:

The 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

src/define.ts
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
        },
    ],
})
FieldDescription
packageThe npm package name.
titlePlugin title (string or per-language object). Used for the sidebar group and the privileges list.
iconSidebar group icon for multi-page plugins.
routes[].pathAdmin URL of the page. Must be unique across plugins.
routes[].title / iconSidebar label and icon of the page.
routes[].componentNamed 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[].prefetchOne 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

src/router.ts
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
publicProcedureAnyone, even signed out. Rarely what you want.
privateProcedureAny 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

src/index.ts
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

src/server.tsx
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 />
}
src/client/MessagesPage.tsx
'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} &lt;{m.email}&gt;
                    </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

src/define.ts
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' },
    ],
})
src/server.tsx
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:

src/index.ts
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:

package.json
{
    "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>
  • definePlugin descriptor exported from the package root
  • createPlugin() lazy-imports the router
  • Procedures use pluginProcedure(PLUGIN_NAME, op?)
  • ./server export with a default component (single route) or named components (multi-route)
  • README lists env vars, options, and the registry line