How to Build Responses in Medusa API Routes: Complete Guide with Code Examples
Medusa API routes build responses by resolving the query service to fetch paginated data, optionally enriching results with tax prices or inventory quantities, and returning a uniform JSON object using the typed MedusaResponse wrapper.
Every route in the MedusaJS framework follows a standardized pattern for constructing HTTP responses. When you build responses in Medusa API routes, you work with a typed wrapper around Express that enforces consistent JSON shapes across store and admin endpoints. This architecture leverages dependency injection, query builders, and utility helpers to transform raw database rows into clean, paginated API outputs.
Core Components: MedusaResponse and Request Types
The foundation of response building starts with two typed objects imported from @medusajs/framework/http.
MedusaResponse is a thin wrapper around Express's res object that adds TypeScript typings for the JSON body. Every route handler uses this to ensure type-safe responses:
import { MedusaResponse, RequestWithContext } from "@medusajs/framework/http"
import { HttpTypes } from "@medusajs/framework/types"
export const GET = async (
req: RequestWithContext<HttpTypes.StoreProductListParams>,
res: MedusaResponse<HttpTypes.StoreProductListResponse>
) => {
// Handler implementation
res.json({ products: [], count: 0, offset: 0, limit: 20 })
}
Request types extend the raw Express request with helpers for filtering, pagination, and context. Store routes typically use RequestWithContext, while admin routes use AuthenticatedMedusaRequest to access authenticated user data and extended filterable fields.
The Query Service Pattern
The canonical way to fetch data uses the query service resolved from the container using ContainerRegistrationKeys.QUERY. This service abstracts database access and returns both data and pagination metadata.
In packages/medusa/src/api/store/products/route.ts, the pattern looks like this:
import { ContainerRegistrationKeys, QueryContext, isPresent } from "@medusajs/framework/utils"
export const GET = async (req, res) => {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const context: object = {}
if (isPresent(req.pricingContext)) {
context["variants"] = { calculated_price: QueryContext(req.pricingContext) }
}
const { data: products = [], metadata } = await query.graph({
entity: "product",
fields: req.queryConfig.fields,
filters: req.filterableFields,
pagination: req.queryConfig.pagination,
context,
})
// Response building continues...
}
The query.graph method (or query.index when using the Index Engine feature flag) returns an object containing data and metadata, which drives the final response structure.
Structuring Paginated Responses
Medusa enforces a uniform pagination format across all list endpoints. The metadata object from the query service contains count (or estimate_count for index queries), skip, and take, which map to count, offset, and limit in the JSON response.
As implemented in packages/medusa/src/api/admin/orders/route.ts, the standard response shape combines the data array with these pagination fields:
res.json({
orders: rows as unknown as HttpTypes.AdminOrder[],
count: metadata.count,
offset: metadata.skip,
limit: metadata.take,
})
This ensures every list endpoint returns a predictable shape for frontend consumers and third-party integrations.
Data Transformation Utilities
Before sending the response, routes often post-process raw database results using utilities from packages/medusa/src/utils/.
cleanResponseData removes internal fields and trims the payload according to the fields query parameter. Located in packages/medusa/src/utils/clean-response-data.ts, this utility filters out sensitive or internal properties like raw_discount_total:
import { cleanResponseData } from "@medusajs/medusa/src/utils/clean-response-data"
const rawData = await someService.list()
const allowedFields = ["id", "email", "total"]
const filtered = cleanResponseData(rawData, allowedFields)
res.json({ customers: filtered })
Additional helpers like wrapProductsWithTaxPrices and wrapVariantsWithInventoryQuantityForSalesChannel attach computed data (tax calculations, inventory levels) that aren't stored directly in the database tables.
Complete Implementation Examples
Store Product Endpoint with Context
The store products route in packages/medusa/src/api/store/products/route.ts demonstrates the full pipeline including pricing context and tax wrapping:
import { MedusaResponse, RequestWithContext } from "@medusajs/framework/http"
import { HttpTypes } from "@medusajs/framework/types"
import {
ContainerRegistrationKeys,
QueryContext,
isPresent,
} from "@medusajs/framework/utils"
export const GET = async (
req: RequestWithContext<HttpTypes.StoreProductListParams>,
res: MedusaResponse<HttpTypes.StoreProductListResponse>
) => {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const context: object = {}
if (isPresent(req.pricingContext)) {
context["variants"] = { calculated_price: QueryContext(req.pricingContext) }
}
const { data: products = [], metadata } = await query.graph({
entity: "product",
fields: req.queryConfig.fields,
filters: req.filterableFields,
pagination: req.queryConfig.pagination,
context,
})
await wrapProductsWithTaxPrices(req, products)
res.json({
products,
count: metadata!.count,
offset: metadata!.skip,
limit: metadata!.take,
})
}
Admin Orders with Workflow Integration
Admin routes often delegate data fetching to workflows. The orders route in packages/medusa/src/api/admin/orders/route.ts formats the workflow result into the standard response shape:
import { getOrdersListWorkflow } from "@medusajs/core-flows"
import { HttpTypes, OrderDTO } from "@medusajs/framework/types"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
export const GET = async (
req: AuthenticatedMedusaRequest<HttpTypes.AdminOrderFilters>,
res: MedusaResponse<HttpTypes.AdminOrderListResponse>
) => {
const variables = {
filters: { ...req.filterableFields, is_draft_order: false },
...req.queryConfig.pagination,
}
const workflow = getOrdersListWorkflow(req.scope)
const { result } = await workflow.run({
input: { fields: req.queryConfig.fields, variables },
})
const { rows, metadata } = result as {
rows: OrderDTO[]
metadata: any
}
res.json({
orders: rows as unknown as HttpTypes.AdminOrder[],
count: metadata.count,
offset: metadata.skip,
limit: metadata.take,
})
}
Summary
- Use
MedusaResponsefrom@medusajs/framework/httpto ensure type-safe JSON outputs across all routes. - Resolve the query service via
req.scope.resolve(ContainerRegistrationKeys.QUERY)to fetch data with built-in pagination support. - Map metadata fields consistently:
metadata.count(ormetadata.estimate_count),metadata.skip, andmetadata.takebecomecount,offset, andlimitin your JSON response. - Apply post-processing utilities like
cleanResponseData(frompackages/medusa/src/utils/clean-response-data.ts) to filter fields and attach computed values such as tax prices or inventory quantities. - Follow the standard pattern: resolve query → execute with filters/pagination → transform data → return uniform JSON shape.
Frequently Asked Questions
How does Medusa handle pagination in API responses?
Medusa uses a metadata object returned by the query service that contains count (total records), skip (offset), and take (limit). These values are mapped directly to the response JSON fields count, offset, and limit, ensuring every list endpoint returns a consistent pagination structure that frontend applications can rely on for infinite scroll or table pagination.
What is the difference between query.graph and query.index in Medusa?
query.graph is the default query method that uses Medusa's standard GraphQL-style query engine, while query.index is used when the Index Engine feature flag is enabled. Both return identical { data, metadata } shapes, so you can build responses in Medusa API routes using the same pattern regardless of which engine is active under the hood.
How do I restrict which fields are returned in a Medusa API response?
Use the cleanResponseData utility from packages/medusa/src/utils/clean-response-data.ts. Pass your raw data array and an array of allowed field names (such as ["id", "email", "total"]) to strip out internal database fields and sensitive data before calling res.json(). This respects the fields query parameter pattern used throughout the Medusa framework.
Can I use workflows to build responses in custom API routes?
Yes, workflows are fully supported in custom routes. Resolve the workflow using req.scope, run it with your filter and pagination variables, then extract rows and metadata from the result. Format these into the standard response shape with count, offset, and limit fields before sending via res.json(), following the pattern established in packages/medusa/src/api/admin/orders/route.ts.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →