SaaS Starter
How-to

Add an endpoint

Add a route + Zod schema + apiRoute registration + permission gate + regen.

You're adding a single endpoint to an existing module (e.g. GET /api/users/me/preferences). For a brand-new module, see add a new feature.

1. Define the contract

packages/contracts/src/user.ts:

export const PreferencesSchema = z.object({
  locale: z.enum(['en', 'es', 'pt']),
  emailDigest: z.boolean(),
});
export type Preferences = z.infer<typeof PreferencesSchema>;

Re-export from the contracts barrel.

2. Add the use case (or extend a query)

// apps/server/src/modules/iam/application/use-cases/get-preferences.ts
export class GetPreferences implements UseCase<{ userId: UserId }, Preferences> {
  constructor(private readonly users: UserRepository) {}

  async execute({ userId }: { userId: UserId }): Promise<Result<Preferences, DomainError>> {
    const user = await this.users.findById(userId);
    if (!user) return err(new NotFoundError('User'));
    return ok({ locale: user.locale, emailDigest: user.emailDigest });
  }
}

Register it in bootstrap/container.ts.

3. Wire the route

// apps/server/src/modules/iam/interfaces/http/users.routes.ts
import { apiRoute } from '@/infrastructure/http/openapi.js';
import { PreferencesSchema } from '@app/contracts';

apiRoute({
  method: 'get',
  path: '/users/me/preferences',
  tags: ['users'],
  responses: {
    200: { description: 'OK', schema: PreferencesSchema },
  },
  security: 'session',
});

router.get(
  '/me/preferences',
  authMiddleware,
  readLimiter,
  // No requirePermission — reading your own preferences is always allowed.
  async (req, res) => {
    const result = await getPreferences.execute({ userId: req.session.userId });
    if (result.isErr()) return sendError(res, result.error);
    res.json(result.value);
  },
);

Mount the right middleware

Use caseMiddleware chain
Public readreadLimiter only
Authenticated readauthMiddlewarereadLimiter
Admin operationauthMiddlewarewriteLimiterrequirePermission('resource:action')
Self-or-admin (e.g. PATCH /users/:id)authMiddlewarewriteLimiterrequirePermissionOrSelf('users:update', (r) => r.params.id)
Validates a bodyappend validateRequest({ body: ContractSchema })
Premium featureappend requireActiveSubscription

The four tier limiters (readLimiter, writeLimiter, authIpLimiter, authEmailLimiter) are memoized — every router gets the same handler back, so memory mode and Redis mode share buckets across routers.

4. Regenerate types

bun run generate:api

Check the diff is scoped:

git diff apps/server/openapi.json apps/client/lib/api/openapi-types.ts

Commit both files in the same change as the route.

5. Use it from the client

import { api } from '@/lib/api/client';

const { data, error } = await api.GET('/api/users/me/preferences');

The path, method, and response type are all checked against the regenerated openapi-types.ts. If you renamed the path between generating the spec and writing the call, the call won't compile.

6. Test

A challenging test exercises the path that would regress. For an endpoint that gates on permissions, that's "user without grant gets 403, user with grant gets 200". For a body-validating endpoint, "missing field returns 422 with the right issues". Avoid tests that only check status codes on the happy path — those don't fail when business logic regresses.

On this page