Adding a route
Where it goes
Section titled “Where it goes”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.
The shape of a route
Section titled “The shape of a route”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)fromserver/middleware.ts, andtoHttp(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.
Rate limits
Section titled “Rate limits”loginRateLimit, emailRateLimit, writeRateLimit and setupRateLimit exist in
server/middleware.ts. Anything that sends email or checks a password takes one.
The client side
Section titled “The client side”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.
Register it in the security test
Section titled “Register it in the security test”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.
If it sends money anywhere
Section titled “If it sends money anywhere”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.