Skill 14 · Building With Medusa
Subchapter 14.1
reference/api-routes.mdMarkdown23 KBView on GitHub
API routes (also called “endpoints”) are the primary way to expose custom functionality to storefronts and admin dashboards.
/store/<rest-of-path>/store/newsletter-signup, /store/custom-search/admin/<rest-of-path>/admin/custom-reports, /admin/bulk-operationsDetailed authentication patterns: See authentication.md
⚠️ CRITICAL: Always validate request bodies using Zod schemas and the validateAndTransformBody middleware.
When you need both authentication AND validation, pass them as an array. NEVER nest validation inside authenticate:
// ✅ CORRECT - Multiple middlewares in array
export default defineMiddlewares({
routes: [
{
matcher: "/store/products/:id/reviews",
method: "POST",
middlewares: [
authenticate("customer", ["session", "bearer"]),
validateAndTransformBody(CreateReviewSchema)
],
},
],
})
// ❌ WRONG - Don't nest validator inside authenticate
export default defineMiddlewares({
routes: [
{
matcher: "/store/products/:id/reviews",
method: "POST",
middlewares: [authenticate("customer", ["session", "bearer"], {
validator: CreateReviewSchema // This doesn't work!
})],
},
],
})Middleware order matters: Put authenticate before validateAndTransformBody so authentication happens first.
// api/store/[feature]/middlewares.ts
import { MiddlewareRoute, validateAndTransformBody } from "@medusajs/framework"
import { z } from "@medusajs/framework/zod"
export const CreateMySchema = z.object({
email: z.email(),
name: z.string().min(2),
// other fields
})
// Export the inferred type for use in route handlers
export type CreateMySchema = z.infer<typeof CreateMySchema>
export const myMiddlewares: MiddlewareRoute[] = [
{
matcher: "/store/my-route",
method: "POST",
middlewares: [validateAndTransformBody(CreateMySchema)],
},
]// api/middlewares.ts
import { defineMiddlewares } from "@medusajs/framework/http"
import { myMiddlewares } from "./store/[feature]/middlewares"
export default defineMiddlewares({
routes: [...myMiddlewares],
})⚠️ CRITICAL - Middleware Export Pattern:
Middlewares are exported as named arrays, NOT default exports with config objects:
// ✅ CORRECT - Named export of MiddlewareRoute array
// api/store/reviews/middlewares.ts
export const reviewMiddlewares: MiddlewareRoute[] = [
{
matcher: "/store/reviews",
method: "POST",
middlewares: [validateAndTransformBody(CreateReviewSchema)],
},
]
// ✅ CORRECT - Import and spread the named array
// api/middlewares.ts
import { reviewMiddlewares } from "./store/reviews/middlewares"
export default defineMiddlewares({
routes: [...reviewMiddlewares],
})// ❌ WRONG - Don't use default export with .config
// api/store/reviews/middlewares.ts
export default {
config: {
routes: [...], // This is NOT the middleware pattern!
},
}
// ❌ WRONG - Don't access .config.routes
// api/middlewares.ts
import reviewMiddlewares from "./store/reviews/middlewares"
export default defineMiddlewares({
routes: [...reviewMiddlewares.config.routes], // This doesn't work!
})Why this matters:
route.ts) use export const config = defineRouteConfig(...)⚠️ CRITICAL: When using req.validatedBody, you MUST pass the inferred Zod schema type as a type argument to MedusaRequest. Otherwise, you’ll get TypeScript errors when accessing req.validatedBody.
// api/store/my-route/route.ts
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { CreateMySchema } from "./middlewares"
// ✅ CORRECT: Pass the Zod schema type as type argument
export async function POST(
req: MedusaRequest<CreateMySchema>,
res: MedusaResponse
) {
// Now req.validatedBody is properly typed
const { email, name } = req.validatedBody
// ... rest of implementation
}
// ❌ WRONG: Without type argument, req.validatedBody will have type errors
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const { email, name } = req.validatedBody // Type error!
}For API routes that accept query parameters, use the validateAndTransformQuery middleware to validate them.
⚠️ IMPORTANT: When using validateAndTransformQuery, access query parameters via req.validatedQuery instead of req.query.
Create a Zod schema for the query parameters. Since query parameters are originally strings or arrays of strings, use z.preprocess to transform them to other types:
// api/custom/validators.ts
import { z } from "@medusajs/framework/zod"
export const GetMyRouteSchema = z.object({
cart_id: z.string(), // String parameters don't need preprocessing
limit: z.preprocess(
(val) => {
if (val && typeof val === "string") {
return parseInt(val)
}
return val
},
z.number().optional()
),
status: z.enum(["active", "pending", "completed"]).optional(),
})// api/middlewares.ts
import {
validateAndTransformQuery,
defineMiddlewares,
} from "@medusajs/framework/http"
import { GetMyRouteSchema } from "./custom/validators"
export default defineMiddlewares({
routes: [
{
matcher: "/store/my-route",
method: "GET",
middlewares: [
validateAndTransformQuery(GetMyRouteSchema, {}),
],
},
],
})// api/store/my-route/route.ts
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
export async function GET(req: MedusaRequest, res: MedusaResponse) {
// Access validated query parameters (not req.query!)
const { cart_id, limit, status } = req.validatedQuery
// cart_id is string, limit is number, status is enum
const query = req.scope.resolve("query")
const { data } = await query.graph({
entity: "my_entity",
fields: ["id", "name"],
filters: { cart_id, status },
})
return res.json({ items: data })
}⚠️ BEST PRACTICE: For API routes that retrieve lists of resources, use request query config to allow clients to control fields, pagination, and ordering.
This pattern:
// api/middlewares.ts
import {
validateAndTransformQuery,
defineMiddlewares,
} from "@medusajs/framework/http"
import { createFindParams } from "@medusajs/medusa/api/utils/validators"
// createFindParams() generates a schema that accepts:
// - fields: Select specific fields/relations
// - offset: Skip N items
// - limit: Max items to return
// - order: Order by field(s) ASC/DESC
export const GetProductsSchema = createFindParams()
export default defineMiddlewares({
routes: [
{
matcher: "/store/products",
method: "GET",
middlewares: [
validateAndTransformQuery(
GetProductsSchema,
{
defaults: [
"id",
"title",
"variants.*", // Include all variant fields by default
],
isList: true, // Indicates this returns a list
defaultLimit: 15, // Default pagination limit
}
),
],
},
],
})Configuration Options:
defaults: Array of default fields and relations to retrieveisList: Boolean indicating if this returns a list (affects pagination)allowed: (Optional) Array of fields/relations allowed in the fields query paramdefaultLimit: (Optional) Default limit if not provided (default: 50)⚠️ CRITICAL: When using req.queryConfig, do NOT explicitly set the fields property in your query. The queryConfig already contains the fields configuration, and setting it explicitly will cause TypeScript errors.
// api/store/products/route.ts
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const query = req.scope.resolve("query")
// ✅ CORRECT: Only use ...req.queryConfig (includes fields, pagination, etc.)
const { data: products } = await query.graph({
entity: "product",
...req.queryConfig, // Contains fields, select, limit, offset, order
})
return res.json({ products })
}
// ❌ WRONG: Don't set fields explicitly when using queryConfig
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const query = req.scope.resolve("query")
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title"], // ❌ Type error! queryConfig already sets fields
...req.queryConfig,
})
return res.json({ products })
}If you need additional filters, only add those - not fields:
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const query = req.scope.resolve("query")
const { id } = req.params
// ✅ CORRECT: Add filters while using queryConfig
const { data: products } = await query.graph({
entity: "product",
filters: { id }, // Additional filters are OK
...req.queryConfig, // Fields come from here
})
return res.json({ products })
}Clients can now control the API response:
// Default response (uses middleware defaults)
GET /store/products
// Returns: id, title, variants.*
// Custom fields selection
GET /store/products?fields=id,title,description
// Returns: only id, title, description
// Pagination
GET /store/products?limit=10&offset=20
// Returns: 10 items, skipping first 20
// Ordering
GET /store/products?order=title
// Returns: products ordered by title ascending
GET /store/products?order=-created_at
// Returns: products ordered by created_at descending (- prefix)
// Combined
GET /store/products?fields=id,title,brand.*&limit=5&order=-created_at
// Returns: 5 items with custom fields, newest firstYou can combine custom query parameters with query config:
// validators.ts
import { z } from "@medusajs/framework/zod"
import { createFindParams } from "@medusajs/medusa/api/utils/validators"
export const GetProductsSchema = z.object({
category_id: z.string().optional(),
in_stock: z.preprocess(
(val) => val === "true",
z.boolean().optional()
),
}).extend(createFindParams().shape)⚠️ Zod v4: Medusa uses Zod v4 (since v2.14.0), where .merge() is removed. Combine schemas with .extend(otherSchema.shape), and always import Zod from @medusajs/framework/zod — not from zod directly.
// route.ts
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const query = req.scope.resolve("query")
const { category_id, in_stock } = req.validatedQuery
const filters: any = {}
if (category_id) filters.category_id = category_id
if (in_stock !== undefined) filters.in_stock = in_stock
const { data: products } = await query.graph({
entity: "product",
filters,
...req.queryConfig, // Still get fields, pagination, order
})
return res.json({ products })
}⚠️ CRITICAL: Always import workflows, modules, and other dependencies at the TOP of the file, never inside the route handler function body.
// api/store/reviews/route.ts
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { createReviewWorkflow } from "../../../workflows/create-review"
import { CreateReviewSchema } from "./middlewares"
export async function POST(
req: MedusaRequest<CreateReviewSchema>,
res: MedusaResponse
) {
const { result } = await createReviewWorkflow(req.scope).run({
input: req.validatedBody
})
return res.json({ review: result })
}// api/store/reviews/route.ts
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
// ❌ WRONG: Don't use dynamic imports in route handlers
const { createReviewWorkflow } = await import("../../../workflows/create-review")
const { result } = await createReviewWorkflow(req.scope).run({
input: req.validatedBody
})
return res.json({ review: result })
}Why this matters:
⚠️ IMPORTANT: Medusa uses only GET, POST and DELETE as a convention.
Don’t use PUT or PATCH.
// api/store/my-route/route.ts
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { MedusaError } from "@medusajs/framework/utils"
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const query = req.scope.resolve("query")
// Query data
const { data: items } = await query.graph({
entity: "entity_name",
fields: ["id", "name"],
})
return res.status(200).json({ items })
}
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const { field } = req.validatedBody
// Execute workflow (mutations should always use workflows)
const { result } = await myWorkflow(req.scope).run({
input: { field },
})
return res.status(200).json({ result })
}// Validated body (from middleware)
const { email, name } = req.validatedBody
// Query parameters
const { page, limit } = req.query
// Route parameters
const { id } = req.params
// Resolve services
const query = req.scope.resolve("query")
const myService = req.scope.resolve("my-module")Use MedusaError for consistent error responses:
import { MedusaError } from "@medusajs/framework/utils"
// Not found
throw new MedusaError(MedusaError.Types.NOT_FOUND, "Resource not found")
// Invalid data
throw new MedusaError(MedusaError.Types.INVALID_DATA, "Invalid input provided")
// Unauthorized
throw new MedusaError(MedusaError.Types.UNAUTHORIZED, "Authentication required")
// Conflict (message is replaced by a default; use NOT_ALLOWED if the client needs details)
throw new MedusaError(MedusaError.Types.CONFLICT, "Operation conflicts with an ongoing transaction")
// Other types: INVALID_STATE, NOT_ALLOWED, DUPLICATE_ERRORMedusa automatically formats errors:
{
"type": "not_found",
"message": "Resource not found"
}All routes under these prefixes are automatically protected:
/admin/* - Requires authenticated admin user/store/customers/me/* - Requires authenticated customerTo protect routes under different prefixes, use the authenticate middleware:
// api/middlewares.ts
import {
defineMiddlewares,
authenticate,
} from "@medusajs/framework/http"
export default defineMiddlewares({
routes: [
// Only allow authenticated admin users
{
matcher: "/custom/admin*",
middlewares: [authenticate("user", ["session", "bearer", "api-key"])],
},
// Only allow authenticated customers
{
matcher: "/store/reviews*",
middlewares: [authenticate("customer", ["session", "bearer"])],
},
],
})⚠️ CRITICAL: For routes protected with authenticate middleware, you MUST use AuthenticatedMedusaRequest instead of MedusaRequest to avoid type errors when accessing req.auth_context.actor_id.
import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http"
// ✅ CORRECT - Use AuthenticatedMedusaRequest for protected routes
export async function POST(
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) {
// For admin routes
const userId = req.auth_context.actor_id // Admin user ID
// For customer routes
const customerId = req.auth_context.actor_id // Customer ID
// Your logic here
}
// ❌ WRONG - Don't use MedusaRequest for protected routes
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const userId = req.auth_context.actor_id // Type error!
}See authentication.md for complete authentication patterns.
⚠️ BEST PRACTICE: Workflows are the standard way to perform mutations (create, update, delete) in Medusa. API routes should execute workflows and return their response.
import { createCustomersWorkflow } from "@medusajs/medusa/core-flows"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const { email } = req.validatedBody
const { result } = await createCustomersWorkflow(req.scope).run({
input: {
customersData: [
{
email,
has_account: false,
},
],
},
})
return res.json({ customer: result[0] })
}import { myCustomWorkflow } from "../../workflows/my-workflow"
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const { data } = req.validatedBody
try {
const { result } = await myCustomWorkflow(req.scope).run({
input: { data },
})
return res.json({ result })
} catch (error) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
error.message
)
}
}Ask MedusaDocs for specific workflow names and their input parameters:
Organize routes by feature or domain:
src/api/
├── admin/
│ ├── custom-reports/
│ │ ├── route.ts
│ │ └── middlewares.ts
│ └── bulk-operations/
│ ├── route.ts
│ └── middlewares.ts
└── store/
├── newsletter/
│ ├── route.ts
│ └── middlewares.ts
└── reviews/
├── route.ts
├── [id]/
│ └── route.ts
└── middlewares.ts// middlewares.ts
import { validateAndTransformQuery } from "@medusajs/framework/http"
import { createFindParams } from "@medusajs/medusa/api/utils/validators"
export const GetMyEntitiesSchema = createFindParams()
export default defineMiddlewares({
routes: [
{
matcher: "/store/my-entities",
method: "GET",
middlewares: [
validateAndTransformQuery(GetMyEntitiesSchema, {
defaults: ["id", "name", "created_at"],
isList: true,
defaultLimit: 15,
}),
],
},
],
})
// route.ts
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const query = req.scope.resolve("query")
const { data, metadata } = await query.graph({
entity: "my_entity",
...req.queryConfig, // Handles fields, pagination automatically
})
return res.json({
items: data,
count: metadata.count,
limit: req.queryConfig.pagination.take,
offset: req.queryConfig.pagination.skip,
})
}// For single resource endpoints, you can still use query config
// middlewares.ts
export const GetMyEntitySchema = createFindParams()
export default defineMiddlewares({
routes: [
{
matcher: "/store/my-entities/:id",
method: "GET",
middlewares: [
validateAndTransformQuery(GetMyEntitySchema, {
defaults: ["id", "name", "variants.*", "brand.*"],
isList: false, // Single resource
}),
],
},
],
})
// route.ts
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const query = req.scope.resolve("query")
const { id } = req.params
const { data } = await query.graph({
entity: "my_entity",
filters: { id },
...req.queryConfig,
})
if (!data || data.length === 0) {
throw new MedusaError(MedusaError.Types.NOT_FOUND, "Resource not found")
}
return res.json({ item: data[0] })
}// validators.ts
export const GetMyEntitiesSchema = z.object({
q: z.string().optional(), // Search query
status: z.enum(["active", "pending", "completed"]).optional(),
}).extend(createFindParams().shape)
// middlewares.ts
export default defineMiddlewares({
routes: [
{
matcher: "/store/my-entities",
method: "GET",
middlewares: [
validateAndTransformQuery(GetMyEntitiesSchema, {
defaults: ["id", "name", "status"],
isList: true,
}),
],
},
],
})
// route.ts
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const query = req.scope.resolve("query")
const { q, status } = req.validatedQuery
const filters: any = {}
if (q) {
filters.name = { $like: `%${q}%` }
}
if (status) {
filters.status = status
}
const { data } = await query.graph({
entity: "my_entity",
filters,
...req.queryConfig, // Client can still control fields, pagination
})
return res.json({ items: data })
}For simple queries where you don’t need client-controlled fields/pagination:
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const query = req.scope.resolve("query")
const { data } = await query.graph({
entity: "my_entity",
fields: ["id", "name"],
filters: { status: "active" },
pagination: {
take: 10,
skip: 0,
},
})
return res.json({ items: data })
}This file
Nearby