Files you will touch, in this order:
packages/shared/src/responses.ts, the response typepackages/shared/src/requests.ts, the request schema, if the route takes a bodyapps/api/src/routes/<domain>/<verb>-<thing>.ts, the handlerapps/api/src/routes/<domain>/index.ts, the routerapps/api/src/index.ts, the mount, only if the domain is new
This walks through adding GET /projects and POST /projects, guarded so that a caller only ever sees their own.
Declare what it returns
// packages/shared/src/responses.ts
export type Project = {
id: string
name: string
/** ISO 8601. */
createdAt: string
}
/** `GET /projects`. */
export type ProjectsResponse = {
projects: Project[]
}
/** `POST /projects`. An action answers with the thing it created. */
export type CreateProjectResponse = {
project: Project
}
Timestamps are ISO 8601 strings, never Date. A Date does not survive JSON.stringify as a Date, so typing one here would be a lie on the wire.
Declare what it accepts
// packages/shared/src/requests.ts
export const createProjectRequestSchema = z.object({
name: z.string().trim().min(1).max(120),
})
export type CreateProjectRequest = z.infer<typeof createProjectRequestSchema>
Requests are Zod schemas rather than types, because the api validates at the edge and the web app reuses the same shape. Responses are types rather than schemas, because the api is their only producer and the contract is a compile-time agreement.
Nothing in this file ever accepts a caller identity. The handler reads that from res.locals.user.
Write the handler
One file per handler, named verb first, so the directory listing reads as the route table.
// apps/api/src/routes/projects/get-projects.ts
import type { ProjectsResponse } from '@ffd/shared'
import { projects } from '@ffd/shared/db'
import { desc, eq } from 'drizzle-orm'
import type { RequestHandler } from 'express'
import { db } from '../../lib/db'
export const getProjects: RequestHandler = async (_req, res) => {
const rows = await db
.select()
.from(projects)
.where(eq(projects.userId, res.locals.user.id))
.orderBy(desc(projects.createdAt))
const body: ProjectsResponse = {
projects: rows.map((row) => ({
id: row.id,
name: row.name,
createdAt: row.createdAt.toISOString(),
})),
}
res.json(body)
}
The caller comes from res.locals.user and nowhere else. Not a path parameter, not ?userId=, not the body. Any of those makes the route a disclosure for anyone who can guess an id, and req.user, request.user and body.user are lint errors in apps/api for exactly that reason.
The write handler parses at the top and lets the throw travel:
// apps/api/src/routes/projects/post-project.ts
export const postProject: RequestHandler = async (req, res) => {
const { name } = createProjectRequestSchema.parse(req.body)
const [row] = await db
.insert(projects)
.values({ userId: res.locals.user.id, name })
.returning()
if (row === undefined) {
throw new AppError(
500,
'project_not_created',
'We could not create that project just now. Try again in a moment.',
)
}
const body: CreateProjectResponse = {
project: { id: row.id, name: row.name, createdAt: row.createdAt.toISOString() },
}
res.status(201).json(body)
}
There is no validate() middleware and no asyncHandler wrapper. The ZodError travels to the error middleware, which turns it into a 400 invalid_request with the flattened issues in details, and Express 5 forwards a rejected promise on its own.
message on an AppError is authored copy, safe to display to a user. Never pass a caught error's message into it. describeError(e) is what goes in a log line, and it returns status, message and response data and nothing else, because JSON.stringify on an HTTP client error walks err.config.headers and writes a bearer token to your logs.
Wire up the router
// apps/api/src/routes/projects/index.ts
import { Router } from 'express'
import { authenticateToken, requireUser } from '../../lib/auth'
import { getProjects } from './get-projects'
import { postProject } from './post-project'
export const projectsRouter = Router()
projectsRouter.use(authenticateToken, requireUser)
projectsRouter.get('/', getProjects)
projectsRouter.post('/', postProject)
Two rules govern this file, and both are about what gets appended next rather than what is there now.
The baseline guard is the first statement after Router(), and later .use() calls may only add guards. A route appended at the bottom, which is what an agent does by default, lands inside the guard, so a misplaced line is a 403 rather than a leak. This is also what lets res.locals.user be typed as present rather than optional.
Static path segments precede /:param ones. GET /projects/search written below GET /projects/:id is shadowed into a silent 404, every time.
A genuinely public route gets its own router. Do not punch a hole in a guarded one.
Mount it
// apps/api/src/index.ts
app.use('/projects', projectsRouter)
Mount it with the other JSON routers, below express.json(). The order of that file is load-bearing and every line in it carries its reason; read the comments before moving anything.
Two exceptions to "mount it with the others". A route that verifies a vendor signature over raw bytes goes above express.json(), with express.raw() applied at the mount. A route that must answer while configuration is what is broken goes above clerkMiddleware(), which is why /health is where it is.
Call it from the web app
const projects = await apiFetch<ProjectsResponse>('/projects', { locale, token })
apiFetch is the only thing in apps/web that talks to the api and the only thing that throws. Every non-2xx becomes an ApiError carrying the api's code, message and details, so the UI renders the sentence the api authored rather than one a frontend developer made up.
Pass the active Locale on every call. The client sends it as Accept-Language, so
the api can localize authored error messages without reading browser state.
Branch on error.code, never on error.message.
Verify
pnpm typecheck
pnpm check
Then exercise it. A route added below its guard answers 401 without a token:
curl -i http://localhost:5001/projects
# HTTP/1.1 401
# {"code":"unauthenticated","message":"You need to be signed in to do that. ..."}
If it answers 200 instead, the route is above the .use() line.
Related
- API routes, every route and every error code
docs/adr/0015-handlers-throw-apperror.md, why there is no validate middlewaredocs/adr/0016-positional-middleware-that-only-tightens.md, the two router rules