Skip to main content
An API route is a thin adapter between HTTP and the rest of the system. It validates the request, runs a workflow for writes or a Query for reads, and shapes the response. No business logic lives here.
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, so res.json(...) is checked and the SDK infers a real return type instead of unknown.
src/api/admin/brands/route.ts
Don’t leave MedusaResponse bare. An untyped response means res.json({...}) accepts anything and the typed SDK resolves that endpoint to an empty or unknown response, the exact opposite of the point of the typed client. Always pass the response generic.

Only GET, POST, DELETE

Mercur routes use only GET, POST, and DELETE. There is no PUT or PATCH. Model an update as a POST to the resource. Keeping to three verbs is what keeps the typed SDK (.query / .mutate / .delete) consistent across every route.

Validation with Zod + exported types

Validation happens in middlewares.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
List and read params use the framework helpers 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
The handler then trusts 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
Both share the same 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:
This is why filtering is declarative and safe. To make a field filterable you add it to the validator. To scope a request you inject onto req.filterableFields in middleware (next section). The handler never builds a where clause by hand.

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 onto req.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
Injecting a filter in middleware means a new route on the same resource is scoped by construction, not by remembering to add a filter. This works with query.graph because is_active lives on the brand’s own module.
You can only filter this way on a field that belongs to the entity’s own module. Filtering by a linked module’s field (e.g. products by their brand) does not work with query.graph. Query aggregates modules after the fact, so there’s no join to filter on. Cross-module filtering requires the Index Module and query.index.

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:
Don’t re-derive or re-check identity inside handlers, and don’t read user or owner ids from the request body. Always take them from req.auth_context (or a context object a scoping middleware populated). Trusting the middleware keeps authorization in one place.

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.
Never take a seller_id from the request body or query to decide ownership. That’s caller-supplied. The only authoritative seller is req.seller_context.seller_id. To scope a vendor list route to the caller’s data, add the filterBySellerId() middleware and every query is constrained automatically, with no per-handler where:
src/api/vendor/offers/middlewares.ts

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
fields replaces defaults unless prefixed. An unprefixed field in the request’s fields param replaces the route’s default set. Prefix with +/- to merge (e.g. +brand.name), or base fields like thumbnail silently drop. This is the medusa-fields-param gotcha.

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> and MedusaResponse<TResponse>, never a bare MedusaResponse.
  • Only GET / POST / DELETE exported. Updates are POST.
  • Query params built with createFindParams / createSelectParams, bodies with Zod, and inferred types exported.
  • Filterable fields declared in the validator. Scoping injected via a filterableFields middleware, not inlined.
  • Identity read from req.auth_context, never the body. On vendor routes, the authoritative seller is req.seller_context.seller_id (set by ensureSellerMiddleware). Scope reads with filterBySellerId().
  • fields prefixed with +/- to merge. Defaults declared in queryConfig.
  • 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.