Routes are Medusa file-based API routes. A
route.ts under src/api/** exports handlers named after HTTP verbs, and a sibling middlewares.ts wires validation and filters. Examples below use a custom Brand module exposed under /admin/brands.Type both the request and the response
Type every handler on both sides. Mirror how Medusa’s own routes are written:AuthenticatedMedusaRequest<TBodyOrQuery>: the generic is the validated body for writes or the query params type for reads.MedusaResponse<TResponse>: the generic is the response shape, sores.json(...)is checked and the SDK infers a real return type instead ofunknown.
src/api/admin/brands/route.ts
Only GET, POST, DELETE
Validation with Zod + exported types
Validation happens inmiddlewares.ts via validateAndTransformBody / validateAndTransformQuery. Every schema exports its inferred type so the handler generic and the SDK share one source of truth.
Bodies are plain Zod objects:
src/api/admin/brands/validators.ts
createFindParams (pagination, fields, and order) and createSelectParams (retrieve) rather than a hand-rolled object. This wires pagination and field selection consistently across every route:
src/api/admin/brands/validators.ts
req.validatedBody and the validated query to already match those types. Never re-validate inside the handler.
List vs retrieve
A list route (GET /admin/brands) and a retrieve route (GET /admin/brands/:id) select fields the same way but differ in their params helper and response shape. Retrieve uses createSelectParams (field selection only, no pagination or filters) and returns a single entity:
src/api/admin/brands/[id]/route.ts
src/api/admin/brands/validators.ts: retrieve params
defaults idea but declare them separately in the query config (list vs retrieve). See queryConfig below.
Filterable fields
req.filterableFields is the parsed, validated filter set produced by validateAndTransformQuery from the query params above. Only fields your validator declares can appear there. An unknown query param is dropped, not passed through. The handler forwards it straight to Query:
Middlewares as filters
Middlewares aren’t only for validation. They’re where you inject scoping filters so handlers stay ignorant of the rule. A small middleware writes ontoreq.filterableFields, and because the handler already forwards that to Query, the scope is applied without the handler knowing. For example, force GET /admin/brands to only ever return active rows:
src/api/admin/brands/middlewares.ts
Trust the auth middleware
Authentication and actor resolution happen in middleware (authenticate), so by the time your handler runs the actor is already established. Trust it. Read identity from the request context, never from the body:
Vendor routes: seller_context
Every route under /vendor/* is already authenticated and seller-scoped. You don’t wire auth yourself. By the time your handler runs, the caller is a verified seller member and the request carries a req.seller_context you can trust:
req.seller_context gives you seller_id, currency_code, and the seller_member, all verified, so you never re-check membership in a handler.
queryConfig and field selection
validateAndTransformQuery takes a query config that controls which fields are selectable, isList, and default pagination. The handler reads the resolved selection from req.queryConfig.fields and pagination from req.queryConfig.pagination.
src/api/admin/brands/query-config.ts
Response types
Declare the response shapes next to the route (or in@mercurjs/types for shared ones) and use them as the MedusaResponse generic. The SDK reads these to type .query() and .mutate() returns:
src/api/admin/brands/types.ts
Checklist for a route
- Handler is thin: validate, run a workflow (writes) or
query.graph(reads), then respond. - Both generics set:
AuthenticatedMedusaRequest<TBody|TQuery>andMedusaResponse<TResponse>, never a bareMedusaResponse. - Only
GET/POST/DELETEexported. Updates arePOST. - Query params built with
createFindParams/createSelectParams, bodies with Zod, and inferred types exported. - Filterable fields declared in the validator. Scoping injected via a
filterableFieldsmiddleware, not inlined. - Identity read from
req.auth_context, never the body. On vendor routes, the authoritative seller isreq.seller_context.seller_id(set byensureSellerMiddleware). Scope reads withfilterBySellerId(). fieldsprefixed with+/-to merge. Defaults declared inqueryConfig.- No mutations outside a workflow.
Next steps
Workflows
Run business logic and the Query engine behind your routes.
Module links
Filter by a linked field with the Index Module and
query.index.