Files
2026-03-07 11:07:45 -03:00

874 lines
23 KiB
Markdown

# 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/<rest-of-path>`
- **Examples**: `/store/newsletter-signup`, `/store/custom-search`
- **Authentication**: SDK automatically includes publishable API key
### Admin API Routes (Dashboard)
- **Path prefix**: `/admin/<rest-of-path>`
- **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<typeof CreateMySchema>
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<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!
}
```
## 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<CreateReviewSchema>,
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 })
}
```