Best practices

HTTP routes

The URL, handler, error, response and test conventions every module API follows, and the server toolkit that implements them.

A client that has called one module's API should be able to guess the next one: its URLs, its errors and the shape of its responses. That client may be your app, an AI agent over the Model Context Protocol (MCP) or a test. This page lists the conventions every module API follows, and the helpers that implement them.

For: module authors. App developers who add API routes to a host app can follow the same conventions; only Registering routes is module-specific.

The helpers are the server toolkit of Module Kit, @frigear-nu/module-kit/server.

Import the toolkit explicitly. It is not auto-imported, so nothing clashes with h3's own helpers.

URLs

  • Module prefix. Everything lives under /api/<module>.
  • Staff routes. They live under /api/admin/<module>/..., with admin first, like the /admin/<module> pages. So a host app can guard all of them with one routeRules['/api/admin/**'].
  • The main resource. It is the module path itself: GET /api/admin/events lists events, and GET /api/admin/events/:id reads one.
  • The caller's own things. Anything that is implicitly the caller's lives under /api/<module>/me/....
  • Ids and collections. Ids are path params, and collections are plural.
  • Actions. An action that is not create, read, update or delete (CRUD) is POST /<resource>/:id/<verb>.
  • Methods. A partial update is PATCH, and replacing a value is PUT. A state change is never GET.
  • Exceptions. Spec endpoints (/.well-known/*, /oauth/*), webhooks and OAuth callbacks registered at a provider keep the paths they need.
  • One place for paths. Keep the paths as typed builders in src/runtime/utils/api.ts. The app, tests and docs use them. See Naming.

Registering routes

A module registers every route from one table in module.ts, with addModuleRoutes from @frigear-nu/module-kit/utils.

FieldMeaning
methodThe HTTP method, in lower case.
pathJoined onto base. '' is base itself, and a path that starts with / ignores base.
fileThe handler, relative to ./runtime/server/routes/.
base (in the third argument)Required. The prefix for every path in the table.
import { addModuleRoutes } from '@frigear-nu/module-kit/utils'

addModuleRoutes(resolver, [
  { method: 'get', path: '', file: 'admin/events.get' },
  { method: 'post', path: '', file: 'admin/events.post' },
  { method: 'get', path: ':id', file: 'admin/event.get' },
  { method: 'patch', path: ':id', file: 'admin/event.patch' },
  { method: 'post', path: ':id/clone', file: 'admin/event-clone.post' },
  { method: 'get', path: '/api/events/me/tickets', file: 'me/tickets.get' },
], { base: '/api/admin/events' })

Handler order

Every handler does its work in the same order:

  1. Authorize, with authorize(event, '<permission>'). It authenticates and checks the permission.
  2. Parse the path params, then the body or query. Use h3's validated helpers with zod: getValidatedRouterParams(event, idParams().parse), readValidatedBody(event, schema.parse) and getValidatedQuery(event, schema.parse).
  3. Load the target, then authorize again with the record when a policy may refine the permission, then act.
Never answer 404 before 401 or 403. A route that loads the record first tells a visitor which ids exist. See Common mistakes.

On JSON routes, use no raw readBody, getQuery or getRouterParam(...)!, and no wrappers around the validated helpers.

Step 1: who may call the route

The first line of a handler decides who may call it, and it is almost always authorize:

RouteFirst lineComes from
Needs a permission (every route that reads or changes data)await authorize(event, 'events:manage')@frigear-nu/auth
Any signed-in caller, no permissionconst { user } = await requireAuth(event)@frigear-nu/auth
The caller's own user recordconst user = await requireUser(event)@frigear-nu/users

authorize returns the principal (user, kind, scopes) and throws 401 unauthorized, or 403 with session_required, insufficient_scope or forbidden. It is auto-imported into server code. The permission is registered once with addPermission; staff routes use the module's own permissions (events:manage, forms:manage), and the caller's own things use implicit permissions such as tickets:read. Who holds a permission is decided by roles, policies and the host's before hook, never by the route. The whole model is on Access control.

sessionOnly is a property of the permission. Do not pass options to make a route stricter. Money and credential actions get their own sessionOnly permission (events:refund, users:manage), so every route that checks it refuses tokens, and a token can never hold it. There is no scopes option on requireAuth or requireUser: name the permission and call authorize.

The two-step route

A route that acts on one record calls authorize twice:

  1. Without the record, first. It answers 401 or 403 from the permission alone, so a visitor cannot tell which ids exist.
  2. With the record, after loading it. It runs the policies, such as "only the owner" or "not once archived".
await authorize(event, 'events:manage') // 401/403 before any 404
const ev = await loadEvent(id) // then 404
await authorize(event, 'events:manage', ev) // record-level policies

A route that has no policy on its permission may make the first call only. Use allows(event, permission, record?) instead when you need a yes or no without throwing, for instance to choose which fields to return.

Example

A staff route that renames a thing. First, the permission, declared once in the module's src/runtime/access.ts and registered from module.ts (Access control):

src/runtime/access.ts
import { defineAccess } from '@frigear-nu/auth/access'

export const thingsAccess = defineAccess('things', {
  permissions: { update: { label: 'Rename things', grantTo: ['admin'] } }, // things:update
})
src/module.ts
registerAccess(nuxt, thingsAccess) // from @frigear-nu/auth/utils

Then the route, which does the steps in order:

src/runtime/server/routes/admin/thing.patch.ts
import { db, schema } from '@nuxthub/db'
import { eq } from 'drizzle-orm'
import { idParams, notFound } from '@frigear-nu/module-kit/server'
import { z } from 'zod'
import { thingsAccess } from '../../../access'

const body = z.object({ name: z.string().trim().min(1).max(120) })

export default defineEventHandler(async (event) => {
  // 1. Who may call: the permission, answered by roles, policies and the host's before hook.
  await authorize(event, thingsAccess.permissions.update)
  // 2. Parse the path params, then the body.
  const { id } = await getValidatedRouterParams(event, idParams().parse)
  const input = await readValidatedBody(event, body.parse)
  // 3. Load the target and act. A missing record is a 404, only after the caller passed step 1.
  const [row] = await db.update(schema.things).set(input).where(eq(schema.things.id, id)).returning()
  if (!row) throw notFound('Thing not found.')
  // toThingDto lives in src/runtime/server/utils too. See Responses.
  return toThingDto(row)
})

defineEventHandler, getValidatedRouterParams and readValidatedBody are h3's, auto-imported by Nitro. The Events module's admin/addon.patch.ts is a real route built this way.

Input

Two zod schemas validate ids the same way in every module. Both are for getValidatedRouterParams.

SchemaAccepts
idParams(name = 'id')A non-empty string.
intIdParams(name = 'id')A positive integer, coerced from the path.
const { id } = await getValidatedRouterParams(event, idParams().parse)
const { id: userId } = await getValidatedRouterParams(event, intIdParams().parse)

Errors

Errors go through apiError(status, code, message, data?) and its shortcuts. Every error has the same shape:

  • The message is the statusMessage, which the interface shows.
  • data.code is a snake_case code a client can test for.
HelperStatusdata.code
apiError(status, code, message, data?)Anycode
badRequest(message?, data?)400bad_request
validationError(issues, message?)400validation_failed, with the issues in data.issues
forbidden(message?, data?)403forbidden
notFound(message?, data?)404not_found
conflict(message?, data?)409conflict
notConfigured(message?, data?)501not_configured

Each helper returns an h3 error for you to throw. authorize and requireAuth answer in the same shape, with the codes unauthorized, session_required, insufficient_scope and forbidden.

import { conflict, notFound } from '@frigear-nu/module-kit/server'

if (!form) throw notFound('Form not found.')
if (await slugTaken(body.slug)) throw conflict('That slug is taken.', { field: 'slug' })

Field issues

Use this when a client needs an issue per field, such as a form that marks each input.

h3's readValidatedBody(event, schema.parse) answers a failed parse with a 400. Its data carries the zod error only as text. So parse the body yourself with schema.safeParse, and throw validationError(issues). The issues arrive in data.issues. The Forms submit route is the example:

const result = definition.schema.safeParse(await readBody(event))
if (!result.success) throw validationError(result.error.issues)

Responses

RouteStatusReturn
Create201created(event, dto)
Update or action200The DTO
Delete or revoke204noContent(event)

Every response is a data transfer object (DTO) that lists its fields explicitly, never a database row:

  • A to<Entity>Dto() function in server/utils builds it.
  • Dates are ISO strings, made with isoDate.
  • The DTO types live in src/types.ts.
HelperPurpose
created(event, dto)Sets 201 and returns dto.
noContent(event)Sets 204 and returns null, for deletes and revokes.
isoDate(value)Turns a Date, epoch milliseconds or a date string into an ISO string, and null or undefined into null.

Pagination

Admin lists are paginated. They answer { items, total, page, pageSize }, built with pageQuery() and paginate().

  • Query keys. They match the table composable useTableQuery of UI: page, pageSize, q, sort, order and the list's filters keys.
  • Page size. The default is 20, and the most a client may ask for is 100.
  • Small lists. Small bounded lists, such as tokens, roles and plans, stay plain arrays.
src/runtime/server/routes/admin/events.get.ts
import { db, schema } from '@nuxthub/db'
import { and, count, eq } from 'drizzle-orm'
import { likeContains, orderByQuery, pageQuery, pageRange, paginate } from '@frigear-nu/module-kit/server'
import { z } from 'zod'

const listQuery = pageQuery({ sort: ['startsAt', 'slug'], filters: { status: z.enum(['draft', 'published']).optional() } })

export default defineEventHandler(async (event) => {
  // Staff only: the host's roles decide who holds `events:manage`. See Staff access.
  await authorize(event, 'events:manage')
  const query = await getValidatedQuery(event, listQuery.parse)
  const where = and(
    query.q ? likeContains(schema.events.slug, query.q) : undefined,
    query.status ? eq(schema.events.status, query.status) : undefined,
  )
  const orderBy = orderByQuery({ startsAt: schema.events.startsAt, slug: schema.events.slug }, query, { tiebreaker: schema.events.id })
  const { limit, offset } = pageRange(query)
  const [rows, [totals]] = await Promise.all([
    db.select().from(schema.events).where(where).orderBy(...orderBy).limit(limit).offset(offset),
    db.select({ total: count() }).from(schema.events).where(where),
  ])
  return paginate({ items: rows.map(toEventDto), total: totals?.total ?? 0, query })
})

toEventDto is the Events module's DTO function, in its server/utils (see Responses). likeContains searches case-insensitively on both dialects. A % or _ in the search does not act as a wildcard.

Staff access

There is no separate staff check. A staff route is a route whose permission only staff roles hold, such as events:manage or forms:manage. A module registers its permissions with addPermission and gives them to a role by name with grantTo, and the host app gives them to its own roles in authorization.roles. Your host app decides who is staff by who holds the role, and can refine with a policy or the before hook.

RouteCheck
A read or an ordinary editawait authorize(event, 'events:manage'). API tokens whose scopes cover it work.
Credentials, money and billingawait authorize(event, 'events:refund'), a sessionOnly permission. Tokens are refused with 403 session_required.
One recordThe two-step route.
A yes or no, such as an MCP tool or a menuawait allows(event, 'events:manage'). Never throws.

authorize returns the principal; load the full user with loadAuthUser(principal.user.id) when a route needs more than the principal holds. The name of anything a module exports from server/utils must start with the module's name, because every module's server utils share one namespace (see Naming).

Naming

Server utils. Every module's server/utils is auto-imported into one Nitro namespace. So an exported name must be unique across all modules. Prefix it with the module: toFormTokenDto and toAuthTokenDto, never a generic toTokenDto.

Two modules that export the same name clash, and one of them silently wins.

Path builders. Each module keeps typed path builders in src/runtime/utils/api.ts, such as authApi, eventsApi and formsApi. It exports them as @frigear-nu/<pkg>/api.

  • Import them from there in app code and tests.
  • Do not import them from ./utils, which also holds build-time helpers that pull in @nuxt/kit.
  • Relative imports inside the package stay relative.
import { authApi } from '@frigear-nu/auth/api'

Testing routes

@frigear-nu/module-kit/testing has two helpers for end-to-end tests. They run against a test fixture started with setup() from @nuxt/test-utils/e2e.

HelperWhat it does
apiClient({ cookie?, bearer? })Acts as one caller.
.json<T>(method, path, body?)Resolves with the parsed body of a 2xx response, or null for 204. Rejects otherwise.
.fetch(method, path, body?)Resolves with the raw Response, whatever its status.
expectApiError(promise, status, code?)Asserts the request failed with status, and with data.code when given. Resolves with the error body.
import { apiClient, expectApiError } from '@frigear-nu/module-kit/testing'

const staff = apiClient({ cookie: staffCookie })
const created = await staff.json<{ id: string }>('POST', '/api/admin/events', { slug: 'party' })
await expectApiError(apiClient({ cookie: memberCookie }).json('DELETE', `/api/admin/events/${created.id}`), 403, 'forbidden')
expect((await staff.fetch('DELETE', `/api/admin/events/${created.id}`)).status).toBe(204)

Next steps

  • Protect a route – apply the first step of the handler order to your own route.
  • Access control – permissions, roles, policies and what fails closed.
  • Module Kit – the whole toolkit, including bearer CORS and same-origin URL helpers.
  • Testing – set up a database-backed test fixture for these tests.
Copyright © 2026