nextjs-cms

API Layer

tRPC routers and procedures, the request context, route handlers, and how the admin app wires them up.

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

FolderContents
trpc/trpc.tsinitTRPC, context, publicProcedure, privateProcedure, pluginProcedure, SuperJSON transformer
trpc/root.tscoreRouters: the built-in routers
trpc/routers/*.tsOne router per domain
trpc/server.tscreateTRPCRouter(): merges core, plugin, and app routers; RSC helpers; fetch handler
actions/*.tsServer functions the routers call (pages.ts, privileges.ts, files.ts)
client/React client provider for the admin
plugin/The subset of tRPC helpers plugins may import

Context and procedures

export const createTRPCContext = async (opts: { headers: Headers }) => {
    const session = await auth() // from the access_token cookie
    return { db, session, ...opts }
}
ProcedureGuard
publicProcedurenone
privateProcedureisAuthedMiddleware: throws UNAUTHORIZED without a session; narrows ctx.session to non-null
pluginProcedure(name, op?)isAuthed + a row in admin_privileges for name (and op in its operations). The dashboard-override plugin bypasses the privilege check.

Section routers check privileges themselves through SectionFactory.getSectionForAdmin() and isAccessAllowed(), returning NOT_FOUND (not FORBIDDEN) so that unauthorized admins can't discover which sections exist.

Core routers

KeyProceduresBacked by
authlogin, logoutauth/lib/actions.ts
adminslist, get, create, update, removeactions/privileges.ts
accountSettingsget, save, changePassword, updateLanguage
configgetLocalizationgetCMSConfig()
navigationgetSidebaractions/pages.ts#getSidebar
hasItemsSectionslistItems, newItem, editItem, deleteItem, deleteLocaleTranslationactions/pages.ts
simpleSectionscreate, deleteLocaleTranslationactions/pages.ts
categorySectionsget, getChildren, deleteItemactions/pages.ts
fieldstagsAutoComplete
filesgetPhotoactions/files.ts
gallerydeletePhoto
logslist, recent, filterOptionslogging
pluginscheckRouteplugins/loader.ts

Reads go through tRPC. Writes of form data (create/edit with files) go through route handlers under apps/cms/app/api/submit/, because they're multipart FormData handled by the submit classes.

createTRPCRouter()

The admin creates one instance:

apps/cms/app/_trpc/server.ts
import 'server-only'
import { createTRPCRouter } from 'nextjs-cms/api/server'

export const trpc = createTRPCRouter()
export type AppRouter = typeof trpc.appRouter
export const { api, HydrateClient } = trpc

It accepts app-owned routers too (createTRPCRouter({ routers: { myFeature } })); keys that collide with core routers throw at startup.

At request time, getMergedRouter() builds { ...coreRouters, ...pluginRouters, ...userRouters }, loading plugins with the plugin loader. The result is cached per config version, so editing cms.config.ts (adding a plugin) takes effect without a restart. Plugin router keys that collide with core or app routers throw.

The instance exposes:

  • handler: { GET, POST } for app/api/trpc/[trpc]/route.ts.
  • api: an async server caller for RSC (await api.navigation.getSidebar()), with React Query hydration helpers.
  • HydrateClient: wraps client trees that use prefetched data.

Server-only boundaries

api/server starts with import 'server-only'. Anything that plugins need (and that must be importable in contexts where server-only would break, such as jiti-loaded plugin code) is re-exported from api/plugin instead. Keep that split when adding exports.

Adding a procedure

  1. Put the logic in actions/ (testable without tRPC).
  2. Add the procedure to the relevant router, with a Zod input.
  3. Pick the guard: privateProcedure plus an explicit privilege check for section data, or pluginProcedure for plugin data.
  4. Translate user-facing errors with getString(key, ctx.session.user.language).
  5. Add a router test next to the router (routers/*.test.ts).