Skip to content

Adding a route

Admin routes go on adminRouter in server/routes/admin.ts. requireAdmin and verifyCsrf are applied once to the whole router, so anything mounted on it is protected by construction and cannot be added unprotected by accident. Never mount an admin endpoint anywhere else.

Public reads go on publicRouter in server/routes/public.ts. Nothing there authenticates or writes, and nothing there may expose a secret; the store payload carries the publishable Stripe key, which is public by design, and never the secret key.

Customer-facing writes (cart sync, account) have their own routers with requireCustomer. A customer session sets req.session.customerId, never adminId, so requireAdmin refuses it exactly like an anonymous request.

Parse, call a repository function, respond.

adminRouter.put("/products/:id/subtitle", async (req, res) => {
const parsed = subtitleInputSchema.safeParse(req.body);
if (!parsed.success) throw httpError(400, "That subtitle could not be read.");
const product = await updateProductSubtitle(req.params.id, parsed.data.subtitle);
if (!product) throw httpError(404, "Product not found.");
res.json(product);
});
  • The input schema lives in shared/api.ts, never inline. It is imported by the admin too, so the form validates the same way the server does.
  • All SQL lives in db/*-repository.ts. A route never touches Drizzle.
  • Errors use httpError(status, message) from server/middleware.ts, and toHttp(error) maps known error types. Messages are user-facing: say what went wrong and what to do.
  • Throwing inside an async handler is fine; Express 5 forwards rejections to the error handler.

loginRateLimit, emailRateLimit, writeRateLimit and setupRateLimit exist in server/middleware.ts. Anything that sends email or checks a password takes one.

Mutations go through csrfPost, csrfPut and csrfDelete from src/lib/api.ts, which fetch the session’s CSRF token once and send it as x-csrf-token. Wrap the call in a hook in src/admin/queries.ts that invalidates both its own query key and the public store key, so the storefront preview updates.

Add the route to MUTATIONS or READS in server/security.test.ts. That test asserts, for every admin route, that an anonymous request gets 401, a customer session gets 401, and a mutation without a CSRF token is refused. This is not optional; it is what stops an unprotected endpoint shipping.

It does not. Prices come from the database, Stripe is written to only by publish and refund, and payment state is written only by the webhook. If the route you are adding wants to do any of those things, read the invariants first and then put the logic in the place that already does it.

Adding a field is the companion walkthrough.