Skip to main content
An API route is a thin adapter between HTTP and the rest of the system. Its whole job is: validate the request, run a workflow (for writes) or a Query (for reads), and shape 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

Every handler is typed on both sides — mirror how Medusa’s own routes are written:
  • AuthenticatedMedusaRequest<TBodyOrQuery> — the generic is the validated body (writes) or query params (reads) type.
  • 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
import {
  AuthenticatedMedusaRequest,
  MedusaResponse,
} from "@medusajs/framework/http"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"

import { createBrandsWorkflow } from "../../../workflows/create-brands"
import {
  AdminCreateBrandType,
  AdminGetBrandsParamsType,
} from "./validators"
import { AdminBrandListResponse, AdminBrandResponse } from "./types"

export const GET = async (
  req: AuthenticatedMedusaRequest<AdminGetBrandsParamsType>,
  res: MedusaResponse<AdminBrandListResponse>
) => {
  const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)

  const { data: brands, metadata } = await query.graph({
    entity: "brand",
    fields: req.queryConfig.fields,
    filters: req.filterableFields,
    pagination: req.queryConfig.pagination,
  })

  res.json({
    brands,
    count: metadata!.count,
    offset: metadata!.skip,
    limit: metadata!.take,
  })
}

export const POST = async (
  req: AuthenticatedMedusaRequest<AdminCreateBrandType>,
  res: MedusaResponse<AdminBrandResponse>
) => {
  const { result } = await createBrandsWorkflow(req.scope).run({
    input: { brands: [req.validatedBody] },
  })

  res.json({ brand: result[0] })
}
Don’t leave MedusaResponse bare. An untyped response means res.json({...}) accepts anything and the typed SDK resolves that endpoint to an empty/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.
VerbMeaningSDK method
GETRead (list or retrieve).query()
POSTCreate and update.mutate()
DELETERemove.delete()

Validation with Zod + exported types

Validation happens in middlewares.ts via validateAndTransformBody / validateAndTransformQuery, and 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
import { z } from "zod"

export const AdminCreateBrand = z.object({
  name: z.string(),
  is_active: z.boolean().optional(),
})

export type AdminCreateBrandType = z.infer<typeof AdminCreateBrand>
List/read params use the framework helpers — createFindParams (pagination + fields + order) and createSelectParams (retrieve) — rather than a hand-rolled object. This is what wires pagination and field selection consistently across every route:
src/api/admin/brands/validators.ts
import { createFindParams, createOperatorMap } from "@medusajs/medusa/api/utils/validators"

export const AdminGetBrandsParams = createFindParams({
  limit: 20,
  offset: 0,
}).merge(
  z.object({
    // declare the fields that may be filtered on
    id: z.union([z.string(), z.array(z.string())]).optional(),
    name: z.string().optional(),
    is_active: z.boolean().optional(),
    created_at: createOperatorMap().optional(), // gt/lt/gte/lte ranges
  })
)

export type AdminGetBrandsParamsType = z.infer<typeof AdminGetBrandsParams>
The handler then trusts req.validatedBody / 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
export const GET = async (
  req: AuthenticatedMedusaRequest<AdminGetBrandParamsType>,
  res: MedusaResponse<AdminBrandResponse>
) => {
  const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)

  const {
    data: [brand],
  } = await query.graph({
    entity: "brand",
    fields: req.queryConfig.fields,
    filters: { id: req.params.id },
  })

  if (!brand) {
    throw new MedusaError(MedusaError.Types.NOT_FOUND, `Brand ${req.params.id} not found`)
  }

  res.json({ brand })
}
src/api/admin/brands/validators.ts — retrieve params
import { createSelectParams } from "@medusajs/medusa/api/utils/validators"

export const AdminGetBrandParams = createSelectParams()
export type AdminGetBrandParamsType = z.infer<typeof AdminGetBrandParams>
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:
const { data: brands, metadata } = await query.graph({
  entity: "brand",
  fields: req.queryConfig.fields,
  filters: req.filterableFields, // e.g. { is_active: true, created_at: { gt: ... } }
  pagination: req.queryConfig.pagination,
})
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; 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
import {
  MedusaRequest,
  MedusaResponse,
  MedusaNextFunction,
} from "@medusajs/framework/http"

const onlyActive = (
  req: MedusaRequest,
  _res: MedusaResponse,
  next: MedusaNextFunction
) => {
  req.filterableFields.is_active = true // a column on the brand module itself
  next()
}

export const adminBrandsMiddlewares = [
  {
    method: ["GET"],
    matcher: "/admin/brands",
    middlewares: [
      validateAndTransformQuery(AdminGetBrandsParams, adminBrandQueryConfig.list),
      onlyActive,
    ],
  },
]
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:
const userId = req.auth_context.actor_id // set by the authenticate middleware
Don’t re-derive or re-check identity inside handlers, and don’t read user/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:
export const POST = async (
  req: AuthenticatedMedusaRequest<VendorCreateOfferType>,
  res: MedusaResponse
) => {
  const sellerId = req.seller_context!.seller_id       // the acting seller
  const currency = req.seller_context!.currency_code
  // ...run a workflow scoped to this seller
}
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 — no per-handler where:
src/api/vendor/offers/middlewares.ts
import { filterBySellerId } from "@mercurjs/core/..."

{
  method: ["GET"],
  matcher: "/vendor/offers",
  middlewares: [
    validateAndTransformQuery(VendorGetOffersParams, vendorOfferQueryConfig.list),
    filterBySellerId(),
  ],
}

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
export const adminBrandQueryConfig = {
  list: {
    defaults: ["id", "name", "is_active", "created_at"],
    isList: true,
  },
  retrieve: {
    defaults: ["id", "name", "is_active"],
  },
}
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() / .mutate() returns:
src/api/admin/brands/types.ts
import { PaginatedResponse } from "@medusajs/framework/types"

export interface AdminBrandResponse {
  brand: BrandDTO
}

export type AdminBrandListResponse = PaginatedResponse<{
  brands: BrandDTO[]
}>

Checklist for a route

  • Handler is thin: validate → run workflow (writes) or query.graph (reads) → 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; 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.