# Custom API Routes API routes (also called "endpoints") are the primary way to expose custom functionality to storefronts and admin dashboards. ## Contents - [Path Conventions](#path-conventions) - [Middleware Validation](#middleware-validation) - [Query Parameter Validation](#query-parameter-validation) - [Request Query Config for List Endpoints](#request-query-config-for-list-endpoints) - [API Route Structure](#api-route-structure) - [Error Handling](#error-handling) - [Protected Routes](#protected-routes) - [Using Workflows in API Routes](#using-workflows-in-api-routes) ## Path Conventions ### Store API Routes (Storefront) - **Path prefix**: `/store/` - **Examples**: `/store/newsletter-signup`, `/store/custom-search` - **Authentication**: SDK automatically includes publishable API key ### Admin API Routes (Dashboard) - **Path prefix**: `/admin/` - **Examples**: `/admin/custom-reports`, `/admin/bulk-operations` - **Authentication**: SDK automatically includes auth headers (bearer/session) **Detailed authentication patterns**: See [authentication.md](authentication.md) ## Middleware Validation **⚠️ CRITICAL**: Always validate request bodies using Zod schemas and the `validateAndTransformBody` middleware. ### Combining Multiple Middlewares When you need both authentication AND validation, pass them as an array. **NEVER nest validation inside authenticate:** ```typescript // ✅ 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. ### Step 1: Create Middleware File ```typescript // api/store/[feature]/middlewares.ts import { MiddlewareRoute, validateAndTransformBody } from "@medusajs/framework" import { z } from "zod" export const CreateMySchema = z.object({ email: z.string().email(), name: z.string().min(2), // other fields }) // Export the inferred type for use in route handlers export type CreateMySchema = z.infer export const myMiddlewares: MiddlewareRoute[] = [ { matcher: "/store/my-route", method: "POST", middlewares: [validateAndTransformBody(CreateMySchema)], }, ] ``` ### Step 2: Register in api/middlewares.ts ```typescript // 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: ```typescript // ✅ 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], }) ``` ```typescript // ❌ 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:** - Middleware files export arrays directly, not config objects - Route files (like `route.ts`) use `export const config = defineRouteConfig(...)` - Don't confuse the two patterns - middlewares are simpler (just an array) ### Step 3: Use Typed req.validatedBody in Route **⚠️ 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`. ```typescript // 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, 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! } ``` ## Query Parameter Validation 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`. ### Step 1: Create Validation Schema 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: ```typescript // api/custom/validators.ts import { z } from "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(), }) ``` ### Step 2: Add Middleware ```typescript // 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, {}), ], }, ], }) ``` ### Step 3: Use Validated Query in Route ```typescript // 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 }) } ``` ## Request Query Config for List Endpoints **⚠️ 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: - Allows clients to specify which fields/relations to retrieve - Enables client-controlled pagination - Supports custom ordering - Provides sensible defaults ### Step 1: Add Middleware with createFindParams ```typescript // 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 retrieve - `isList`: Boolean indicating if this returns a list (affects pagination) - `allowed`: (Optional) Array of fields/relations allowed in the `fields` query param - `defaultLimit`: (Optional) Default limit if not provided (default: 50) ### Step 2: Use Query Config in Route **⚠️ 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. ```typescript // 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: ```typescript 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 }) } ``` ### Step 3: Client Usage Examples Clients can now control the API response: ```typescript // 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 first ``` ### Advanced: Custom Query Param + Query Config You can combine custom query parameters with query config: ```typescript // validators.ts import { z } from "zod" import { createFindParams } from "@medusajs/medusa/api/utils/validators" export const GetProductsSchema = createFindParams().merge( z.object({ category_id: z.string().optional(), in_stock: z.preprocess( (val) => val === "true", z.boolean().optional() ), }) ) ``` ```typescript // 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 }) } ``` ## Import Organization **⚠️ CRITICAL**: Always import workflows, modules, and other dependencies at the TOP of the file, never inside the route handler function body. ### ✅ CORRECT - Imports at Top ```typescript // 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, res: MedusaResponse ) { const { result } = await createReviewWorkflow(req.scope).run({ input: req.validatedBody }) return res.json({ review: result }) } ``` ### ❌ WRONG - Dynamic Imports in Route Body ```typescript // 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:** - Dynamic imports add unnecessary overhead to every request - Makes code harder to read and maintain - Breaks static analysis and TypeScript checking - Can cause module resolution issues in production ## API Route Structure **⚠️ IMPORTANT**: Medusa uses only GET, POST and DELETE as a convention. - **GET** for reads - **POST** for mutations (create/update) - **DELETE** for deletions Don't use PUT or PATCH. ### Basic API Route ```typescript // 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 }) } ``` ### Accessing Request Data ```typescript // 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") ``` ## Error Handling Use `MedusaError` for consistent error responses: ```typescript 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 throw new MedusaError(MedusaError.Types.CONFLICT, "Resource already exists") // Other types: INVALID_STATE, NOT_ALLOWED, DUPLICATE_ERROR ``` ### Error Response Format Medusa automatically formats errors: ```json { "type": "not_found", "message": "Resource not found" } ``` ## Protected Routes ### Default Protected Routes All routes under these prefixes are automatically protected: - `/admin/*` - Requires authenticated admin user - `/store/customers/me/*` - Requires authenticated customer ### Custom Protected Routes To protect routes under different prefixes, use the `authenticate` middleware: ```typescript // 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"])], }, ], }) ``` ### Accessing Authenticated User **⚠️ 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`. ```typescript 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](authentication.md) for complete authentication patterns.** ## Using Workflows in API Routes **⚠️ BEST PRACTICE**: Workflows are the standard way to perform mutations (create, update, delete) in Medusa. API routes should execute workflows and return their response. ### Example: Create Workflow ```typescript 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] }) } ``` ### Example: Custom Workflow ```typescript 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 ) } } ``` ## Common Built-in Workflows Ask MedusaDocs for specific workflow names and their input parameters: - Customer workflows: create, update, delete customers - Product workflows: create, update, delete products - Order workflows: create, cancel, fulfill orders - Cart workflows: create, update, complete carts - And many more... ## API Route Organization 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 ``` ## Common Patterns ### Pattern: List with Query Config (Recommended) ```typescript // 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, }) } ``` ### Pattern: Retrieve Single Resource with Relations ```typescript // 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] }) } ``` ### Pattern: Search with Custom Filters + Query Config ```typescript // validators.ts export const GetMyEntitiesSchema = createFindParams().merge( z.object({ q: z.string().optional(), // Search query status: z.enum(["active", "pending", "completed"]).optional(), }) ) // 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 }) } ``` ### Pattern: Manual Query (When Query Config Not Needed) For simple queries where you don't need client-controlled fields/pagination: ```typescript 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 }) } ```