
Building With Medusa
- 3.1k installs
- 207 repo stars
- Updated July 31, 2026
- medusajs/medusa-agent-skills
building-with-medusa is an agent skill that implements Medusa backend modules, workflows, and API routes following strict layer separation and query.graph data access patterns.
About
building-with-medusa is the required agent skill for Medusa backend development covering custom modules, workflows, API routes, data models, module links, and business logic placement. The critical architecture flow is Module data models and CRUD, Workflow business logic with rollback, API Route HTTP interface, then Frontend SDK consumers. Key conventions restrict HTTP methods to GET, POST, and DELETE never PUT or PATCH, require workflows for all mutations, and place validation in workflow steps not routes. Reference files must load before implementation: custom-modules.md, workflows.md, api-routes.md, module-links.md, querying-data.md, and authentication.md as applicable. Critical rules include arch-workflow-required, type-request-schema with Zod inferred MedusaRequest types, and data-price-format stating prices store as-is not cents. Data access uses query.graph for cross-module retrieval and query.index for filtering linked modules, avoiding JavaScript filter on linked data. Module names must be camelCase without dashes and linkable is automatic on data models. Developers reach for it whenever planning or implementing Medusa modules, workflows, admin or store routes, or cross-m.
- Enforces Module to Workflow to API Route to Frontend SDK architecture without layer bypass.
- Requires workflows for all mutations and limits HTTP methods to GET, POST, and DELETE only.
- Mandates loading reference files like workflows.md and api-routes.md before writing code.
- data-price-format rule stores prices as-is, never multiplying by 100 for cents conversion.
- Uses query.graph and query.index patterns instead of JavaScript filter on linked module data.
Building With Medusa by the numbers
- 3,110 all-time installs (skills.sh)
- +110 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #183 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 4, 2026 (Skillselion catalog sync)
building-with-medusa capabilities & compatibility
- Capabilities
- medusa module and data model design · workflow mutation patterns · api route validation middleware · query.graph and query.index data access
- Works with
- stripe
- Use cases
- api development · orchestration
- Runs
- Local or remote
What building-with-medusa says it does
ALWAYS follow this flow - never bypass layers
Use workflows for ALL mutations
Prices are stored as-is in Medusa (49.99 stored as 49.99, NOT in cents)
npx skills add https://github.com/medusajs/medusa-agent-skills --skill building-with-medusaAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3.1k |
|---|---|
| repo stars | ★ 207 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 31, 2026 |
| Repository | medusajs/medusa-agent-skills ↗ |
How do I add a Medusa custom module, workflow, and API route without bypassing layers or breaking price and query conventions?
Implement Medusa backend features following module, workflow, and API route layers with query.graph patterns and mutation safety rules.
Who is it for?
Developers implementing Medusa backend features including modules, workflows, store or admin routes, and module links.
Skip if: Skip for storefront UI work use building-storefronts, or admin dashboard widgets use building-admin-dashboard-customizations.
When should I use this skill?
User plans or implements Medusa modules, workflows, API routes, module links, authentication, or cross-module queries.
What you get
Medusa backend code with module CRUD, workflow mutations, validated API routes, and correct query.graph or query.index data retrieval.
- Custom Medusa API route handlers
- Middleware validation config
- Workflow-integrated endpoints
By the numbers
- Readme covers 8 topic areas from path conventions through workflow usage in API routes
Files
Medusa Backend Development
Comprehensive backend development guide for Medusa applications. Contains patterns across 6 categories covering architecture, type safety, business logic placement, and common pitfalls.
When to Apply
Load this skill for ANY backend development task, including:
- Creating or modifying custom modules and data models
- Implementing workflows for mutations
- Building API routes (store or admin)
- Defining module links between entities
- Writing business logic or validation
- Querying data across modules
- Implementing authentication/authorization
Also load these skills when:
- building-admin-dashboard-customizations: Building admin UI (widgets, pages, forms)
- building-storefronts: Calling backend API routes from storefronts (SDK integration)
CRITICAL: Load Reference Files When Needed
The quick reference below is NOT sufficient for implementation. You MUST load relevant reference files before writing code for that component.
Load these references based on what you're implementing:
- Creating a module? → MUST load
reference/custom-modules.mdfirst - Creating workflows? → MUST load
reference/workflows.mdfirst - Creating API routes? → MUST load
reference/api-routes.mdfirst - Creating module links? → MUST load
reference/module-links.mdfirst - Querying data? → MUST load
reference/querying-data.mdfirst - Adding authentication? → MUST load
reference/authentication.mdfirst
Minimum requirement: Load at least 1-2 reference files relevant to your specific task before implementing.
Critical Architecture Pattern
ALWAYS follow this flow - never bypass layers:
Module (data models + CRUD operations)
↓ used by
Workflow (business logic + mutations with rollback)
↓ executed by
API Route (HTTP interface, validation middleware)
↓ called by
Frontend (admin dashboard/storefront via SDK)Key conventions:
- Only GET, POST, DELETE methods (never PUT/PATCH)
- Workflows are required for ALL mutations
- Business logic belongs in workflow steps, NOT routes
- Query with
query.graph()for cross-module data retrieval - Query with
query.index()(Index Module) for filtering across separate modules with links - Module links maintain isolation between modules
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Architecture Violations | CRITICAL | arch- |
| 2 | Type Safety | CRITICAL | type- |
| 3 | Business Logic Placement | HIGH | logic- |
| 4 | Import & Code Organization | HIGH | import- |
| 5 | Data Access Patterns | MEDIUM (includes CRITICAL price rule) | data- |
| 6 | File Organization | MEDIUM | file- |
Quick Reference
1. Architecture Violations (CRITICAL)
arch-workflow-required- Use workflows for ALL mutations, never call module services from routesarch-layer-bypass- Never bypass layers (route → service without workflow)arch-http-methods- Use only GET, POST, DELETE (never PUT/PATCH)arch-module-isolation- Use module links, not direct cross-module service callsarch-query-config-fields- Don't set explicitfieldswhen usingreq.queryConfig
2. Type Safety (CRITICAL)
type-request-schema- Pass Zod inferred type toMedusaRequest<T>when usingreq.validatedBodytype-authenticated-request- UseAuthenticatedMedusaRequestfor protected routes (notMedusaRequest)type-export-schema- Export both Zod schema AND inferred type from middlewarestype-linkable-auto- Never add.linkable()to data models (automatically added)type-module-name-camelcase- Module names MUST be camelCase, never use dashes (causes runtime errors)
3. Business Logic Placement (HIGH)
logic-workflow-validation- Put business validation in workflow steps, not API routeslogic-ownership-checks- Validate ownership/permissions in workflows, not routeslogic-module-service- Keep modules simple (CRUD only), put logic in workflows
4. Import & Code Organization (HIGH)
import-top-level- Import workflows/modules at file top, never useawait import()in route bodyimport-static-only- Use static imports for all dependenciesimport-no-dynamic-routes- Dynamic imports add overhead and break type checking
5. Data Access Patterns (MEDIUM)
data-price-format- CRITICAL: Prices are stored as-is in Medusa (49.99 stored as 49.99, NOT in cents). Never multiply by 100 when saving or divide by 100 when displayingdata-query-method- Usequery.graph()for retrieving data; usequery.index()(Index Module) for filtering across linked modulesdata-query-graph- Usequery.graph()for cross-module queries with dot notation (without cross-module filtering)data-query-index- Usequery.index()when filtering by properties of linked data models in separate modulesdata-list-and-count- UselistAndCountfor single-module paginated queriesdata-linked-filtering-query.graph()can't filter by linked module fields - usequery.index()or query from that entity directlydata-no-js-filter- Don't use JavaScript.filter()on linked data - use database filters (query.index()or query the entity)data-same-module-ok- Can filter by same-module relations withquery.graph()(e.g., product.variants)data-auth-middleware- Trustauthenticatemiddleware, don't manually checkreq.auth_context
6. File Organization (MEDIUM)
file-workflow-steps- Recommended: Create steps insrc/workflows/steps/[name].tsfile-workflow-composition- Composition functions insrc/workflows/[name].tsfile-middleware-exports- Export schemas and types from middleware filesfile-links-directory- Define module links insrc/links/[name].ts
Workflow Composition Rules
The workflow function has critical constraints:
// ✅ CORRECT
const myWorkflow = createWorkflow(
"name",
function (input) { // Regular function, not async, not arrow
const result = myStep(input) // No await
return new WorkflowResponse(result)
}
)
// ❌ WRONG
const myWorkflow = createWorkflow(
"name",
async (input) => { // ❌ No async, no arrow functions
const result = await myStep(input) // ❌ No await
if (input.condition) { /* ... */ } // ❌ No conditionals
return new WorkflowResponse(result)
}
)Constraints:
- No async/await (runs at load time)
- No arrow functions (use
function) - No conditionals/ternaries (use
when()) - No variable manipulation (use
transform()) - No date creation (use
transform()) - Multiple step calls need
.config({ name: "unique-name" })to avoid conflicts
Common Mistakes Checklist
Before implementing, verify you're NOT doing these:
Architecture:
- [ ] Calling module services directly from API routes
- [ ] Using PUT or PATCH methods
- [ ] Bypassing workflows for mutations
- [ ] Setting
fieldsexplicitly withreq.queryConfig - [ ] Skipping migrations after creating module links
Type Safety:
- [ ] Forgetting
MedusaRequest<SchemaType>type argument - [ ] Using
MedusaRequestinstead ofAuthenticatedMedusaRequestfor protected routes - [ ] Not exporting Zod inferred type from middlewares
- [ ] Adding
.linkable()to data models - [ ] Using dashes in module names (must be camelCase)
Business Logic:
- [ ] Validating business rules in API routes
- [ ] Checking ownership in routes instead of workflows
- [ ] Manually checking
req.auth_context?.actor_idwhen middleware already applied
Imports:
- [ ] Using
await import()in route handler bodies - [ ] Dynamic imports for workflows or modules
Data Access:
- [ ] CRITICAL: Multiplying prices by 100 when saving or dividing by 100 when displaying (prices are stored as-is: $49.99 = 49.99)
- [ ] Filtering by linked module fields with
query.graph()(usequery.index()or query from other side instead) - [ ] Using JavaScript
.filter()on linked data (usequery.index()or query the linked entity directly) - [ ] Not using
query.graph()for cross-module data retrieval - [ ] Using
query.graph()when you need to filter across separate modules (usequery.index()instead)
Validating Implementation
CRITICAL: Always run the build command after completing implementation to catch type errors and runtime issues.
When to Validate
- After implementing any new feature
- After making changes to modules, workflows, or API routes
- Before marking tasks as complete
- Proactively, without waiting for the user to ask
How to Run Build
Detect the package manager and run the appropriate command:
npm run build # or pnpm build / yarn buildHandling Build Errors
If the build fails: 1. Read the error messages carefully 2. Fix type errors, import issues, and syntax errors 3. Run the build again to verify the fix 4. Do NOT mark implementation as complete until build succeeds
Common build errors:
- Missing imports or exports
- Type mismatches (e.g., missing
MedusaRequest<T>type argument) - Incorrect workflow composition (async functions, conditionals)
Next Steps - Testing Your Implementation
After successfully implementing a feature, always provide these next steps to the user:
1. Start the Development Server
If the server isn't already running, start it:
npm run dev # or pnpm dev / yarn dev2. Access the Admin Dashboard
Open your browser and navigate to:
- Admin Dashboard: http://localhost:9000/app
Log in with your admin credentials to test any admin-related features.
3. Test API Routes
If you implemented custom API routes, list them for the user to test:
Admin Routes (require authentication):
POST http://localhost:9000/admin/[your-route]- Description of what it doesGET http://localhost:9000/admin/[your-route]- Description of what it does
Store Routes (public or customer-authenticated):
POST http://localhost:9000/store/[your-route]- Description of what it doesGET http://localhost:9000/store/[your-route]- Description of what it does
Testing with cURL example:
# Admin route (requires authentication)
curl -X POST http://localhost:9000/admin/reviews/123/approve \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
--cookie "connect.sid=YOUR_SESSION_COOKIE"
# Store route
curl -X POST http://localhost:9000/store/reviews \
-H "Content-Type: application/json" \
-d '{"product_id": "prod_123", "rating": 5, "comment": "Great product!"}'4. Additional Testing Steps
Depending on what was implemented, mention:
- Workflows: Test mutation operations and verify rollback on errors
- Subscribers: Trigger events and check logs for subscriber execution
- Scheduled jobs: Wait for job execution or check logs for cron output
Format for Presenting Next Steps
Always present next steps in a clear, actionable format after implementation:
## Implementation Complete
The [feature name] has been successfully implemented. Here's how to test it:
### Start the Development Server
[server start command based on package manager]
### Access the Admin Dashboard
Open http://localhost:9000/app in your browser
### Test the API Routes
I've added the following routes:
**Admin Routes:**
- POST /admin/[route] - [description]
- GET /admin/[route] - [description]
**Store Routes:**
- POST /store/[route] - [description]
### What to Test
1. [Specific test case 1]
2. [Specific test case 2]
3. [Specific test case 3]How to Use
For detailed patterns and examples, load reference files:
reference/custom-modules.md - Creating modules with data models
reference/workflows.md - Workflow creation and step patterns
reference/api-routes.md - API route structure and validation
reference/module-links.md - Linking entities across modules
reference/querying-data.md - Query patterns and filtering rules
reference/authentication.md - Protecting routes and accessing users
reference/error-handling.md - MedusaError types and patterns
reference/scheduled-jobs.md - Cron jobs and periodic tasks
reference/subscribers-and-events.md - Event handling
reference/troubleshooting.md - Common errors and solutionsEach reference file contains:
- Step-by-step implementation checklists
- Correct vs incorrect code examples
- TypeScript patterns and type safety
- Common pitfalls and solutions
When to Use This Skill vs MedusaDocs MCP Server
⚠️ CRITICAL: This skill should be consulted FIRST for planning and implementation.
Use this skill for (PRIMARY SOURCE):
- Planning - Understanding how to structure Medusa backend features
- Architecture - Module → Workflow → API Route patterns
- Best practices - Correct vs incorrect code patterns
- Critical rules - What NOT to do (common mistakes and anti-patterns)
- Implementation patterns - Step-by-step guides with checklists
Use MedusaDocs MCP server for (SECONDARY SOURCE):
- Specific method signatures after you know which method to use
- Built-in module configuration options
- Official type definitions
- Framework-level configuration details
Why skills come first:
- Skills contain opinionated guidance and anti-patterns MCP doesn't have
- Skills show architectural patterns needed for planning
- MCP is reference material; skills are prescriptive guidance
Integration with Frontend Applications
⚠️ CRITICAL: Frontend applications MUST use the Medusa JS SDK for ALL API requests
When building features that span backend and frontend:
For Admin Dashboard: 1. Backend (this skill): Module → Workflow → API Route 2. Frontend: Load building-admin-dashboard-customizations skill 3. Connection:
- Built-in endpoints: Use existing SDK methods (
sdk.admin.product.list()) - Custom API routes: Use
sdk.client.fetch("/admin/my-route") - NEVER use regular fetch() - missing auth headers will cause errors
For Storefronts: 1. Backend (this skill): Module → Workflow → API Route 2. Frontend: Load building-storefronts skill 3. Connection:
- Built-in endpoints: Use existing SDK methods (
sdk.store.product.list()) - Custom API routes: Use
sdk.client.fetch("/store/my-route") - NEVER use regular fetch() - missing publishable API key will cause errors
Why the SDK is required:
- Store routes need
x-publishable-api-keyheader - Admin routes need
Authorizationand session headers - SDK handles all required headers automatically
- Regular fetch() without headers → authentication/authorization errors
See respective frontend skills for complete integration patterns.
Custom API Routes
API routes (also called "endpoints") are the primary way to expose custom functionality to storefronts and admin dashboards.
Contents
- Path Conventions
- Middleware Validation
- Query Parameter Validation
- Request Query Config for List Endpoints
- API Route Structure
- Error Handling
- Protected 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
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:
// ✅ 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
// 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
// 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:
- Middleware files export arrays directly, not config objects
- Route files (like
route.ts) useexport 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.
// 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:
// 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
// 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
// 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
// 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 thefieldsquery paramdefaultLimit: (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.
// 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 })
}Step 3: Client Usage Examples
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 firstAdvanced: Custom Query Param + Query Config
You can combine custom query parameters with query config:
// 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()
),
})
)// 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
// 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
// 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
// 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
// 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:
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_ERRORError Response Format
Medusa automatically formats errors:
{
"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:
// 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.
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
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
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.tsCommon Patterns
Pattern: List with Query Config (Recommended)
// 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
// 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
// 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:
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 })
}Authentication in Medusa
Authentication in Medusa secures API routes and ensures only authorized users can access protected resources.
Contents
- Default Protected Routes
- Authentication Methods
- Custom Protected Routes
- Accessing Authenticated User
- Authentication Patterns
Default Protected Routes
Medusa automatically protects certain route prefixes:
Admin Routes (/admin/*)
- Who can access: Authenticated admin users only
- Authentication methods: Session, Bearer token, API key
- Example:
/admin/products,/admin/custom-reports
Customer Routes (/store/customers/me/*)
- Who can access: Authenticated customers only
- Authentication methods: Session, Bearer token
- Example:
/store/customers/me/orders,/store/customers/me/addresses
These routes require no additional configuration - authentication is handled automatically by Medusa.
Authentication Methods
Session Authentication
- Used after login via email/password
- Cookie-based session management
- Automatically handled by Medusa SDK
Bearer Token (JWT)
- Token-based authentication
- Passed in
Authorization: Bearer <token>header - Used by frontend applications
API Key
- Admin-only authentication method
- Used for server-to-server communication
- Passed in
x-medusa-access-tokenheader
Custom Protected Routes
⚠️ CRITICAL: Only add `authenticate` middleware to routes OUTSIDE the default prefixes.
Routes with these prefixes are automatically authenticated - do NOT add middleware:
/admin/*- Already requires authenticated admin user/store/customers/me/*- Already requires authenticated customer
// ✅ CORRECT - Custom route needs authenticate middleware
export default defineMiddlewares({
routes: [
{
matcher: "/store/reviews*", // Not a default protected prefix
middlewares: [authenticate("customer", ["session", "bearer"])],
},
],
})
// ❌ WRONG - /admin routes are automatically authenticated
export default defineMiddlewares({
routes: [
{
matcher: "/admin/reports*", // Already protected!
middlewares: [authenticate("user", ["session", "bearer"])], // Redundant!
},
],
})To protect custom routes outside the default prefixes, use the authenticate middleware.
Protecting Custom Admin Routes
// api/middlewares.ts
import {
defineMiddlewares,
authenticate,
} from "@medusajs/framework/http"
export default defineMiddlewares({
routes: [
{
matcher: "/custom/admin*",
middlewares: [
authenticate("user", ["session", "bearer", "api-key"])
],
},
],
})Parameters:
- First parameter:
"user"for admin users,"customer"for customers - Second parameter: Array of allowed authentication methods
Protecting Custom Customer Routes
// api/middlewares.ts
import {
defineMiddlewares,
authenticate,
} from "@medusajs/framework/http"
export default defineMiddlewares({
routes: [
{
matcher: "/store/reviews*",
middlewares: [
authenticate("customer", ["session", "bearer"])
],
},
],
})Multiple Protected Routes
// api/middlewares.ts
export default defineMiddlewares({
routes: [
// Protect custom admin routes
{
matcher: "/custom/admin*",
middlewares: [authenticate("user", ["session", "bearer", "api-key"])],
},
// Protect custom customer routes
{
matcher: "/store/reviews*",
middlewares: [authenticate("customer", ["session", "bearer"])],
},
// Protect wishlist routes
{
matcher: "/store/wishlists*",
middlewares: [authenticate("customer", ["session", "bearer"])],
},
],
})Accessing Authenticated User
Once a route is protected with the authenticate middleware, you can access the authenticated user's information via req.auth_context.
⚠️ CRITICAL - Type Safety: For protected routes, you MUST use AuthenticatedMedusaRequest instead of MedusaRequest to avoid type errors when accessing req.auth_context.actor_id.
⚠️ CRITICAL - Manual Validation: Do NOT manually validate authentication in your route handlers when using the authenticate middleware. The middleware already ensures the user is authenticated - manual checks are redundant and indicate a misunderstanding of how middleware works.
✅ CORRECT - Using AuthenticatedMedusaRequest
// api/store/reviews/[id]/route.ts
// Middleware already applied: authenticate("customer", ["session", "bearer"])
import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { deleteReviewWorkflow } from "../../../../workflows/delete-review"
export async function DELETE(
req: AuthenticatedMedusaRequest, // ✅ Use AuthenticatedMedusaRequest for protected routes
res: MedusaResponse
) {
const { id } = req.params
// ✅ CORRECT: Just use req.auth_context.actor_id directly
// The authenticate middleware guarantees this exists
const customerId = req.auth_context.actor_id // No type error!
// Pass to workflow - let the workflow handle business logic validation
const { result } = await deleteReviewWorkflow(req.scope).run({
input: {
reviewId: id,
customerId, // Workflow will validate if review belongs to customer
},
})
return res.json({ success: true })
}❌ WRONG - Using MedusaRequest for Protected Routes
// api/store/reviews/[id]/route.ts
// Middleware already applied: authenticate("customer", ["session", "bearer"])
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
export async function DELETE(
req: MedusaRequest, // ❌ WRONG: Should use AuthenticatedMedusaRequest
res: MedusaResponse
) {
const { id } = req.params
const customerId = req.auth_context.actor_id // ❌ Type error: auth_context might be undefined
return res.json({ success: true })
}❌ WRONG - Manual Authentication Check
// api/store/reviews/[id]/route.ts
// Middleware already applied: authenticate("customer", ["session", "bearer"])
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { MedusaError } from "@medusajs/framework/utils"
export async function DELETE(req: MedusaRequest, res: MedusaResponse) {
const { id } = req.params
// ❌ WRONG: Don't manually check if user is authenticated
// The authenticate middleware already did this!
if (!req.auth_context?.actor_id) {
throw new MedusaError(
MedusaError.Types.UNAUTHORIZED,
"You must be authenticated"
)
}
const customerId = req.auth_context.actor_id
// Also wrong: don't validate business logic in routes
// (see workflows.md for why this should be in the workflow)
return res.json({ success: true })
}Why manual checks are wrong:
- The
authenticatemiddleware already validates authentication - If authentication failed, the request never reaches your handler
- Manual checks suggest you don't trust or understand the middleware
- Adds unnecessary code and potential bugs
In Admin Routes
// api/admin/custom/route.ts
import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http"
export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
// Get authenticated admin user ID
const userId = req.auth_context.actor_id
const logger = req.scope.resolve("logger")
logger.info(`Request from admin user: ${userId}`)
// Use userId to filter data or track actions
// ...
return res.json({ success: true })
}In Customer Routes
// api/store/reviews/route.ts
import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http"
export async function POST(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
// Get authenticated customer ID
const customerId = req.auth_context.actor_id
const { product_id, rating, comment } = req.validatedBody
// Create review associated with the authenticated customer
const { result } = await createReviewWorkflow(req.scope).run({
input: {
customer_id: customerId, // From authenticated context
product_id,
rating,
comment,
},
})
return res.json({ review: result })
}Authentication Patterns
Pattern: User-Specific Data
// api/admin/my-reports/route.ts
export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
const userId = req.auth_context.actor_id
const query = req.scope.resolve("query")
// Get reports created by this admin user
const { data: reports } = await query.graph({
entity: "report",
fields: ["id", "title", "created_at"],
filters: {
created_by: userId,
},
})
return res.json({ reports })
}Pattern: Ownership Validation
⚠️ IMPORTANT: Ownership validation is business logic and should be done in workflow steps, not API routes. The route should only pass the authenticated user ID to the workflow, and the workflow validates ownership.
// api/store/reviews/[id]/route.ts
// ✅ CORRECT - Pass user ID to workflow, let workflow validate ownership
export async function DELETE(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
const customerId = req.auth_context.actor_id
const { id } = req.params
// Pass to workflow - workflow will validate ownership
const { result } = await deleteReviewWorkflow(req.scope).run({
input: {
reviewId: id,
customerId, // Workflow validates this review belongs to this customer
},
})
return res.json({ success: true })
}
// ❌ WRONG - Don't validate ownership in the route
export async function DELETE(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
const customerId = req.auth_context.actor_id
const { id } = req.params
const query = req.scope.resolve("query")
// ❌ WRONG: Don't check ownership in the route
const { data: reviews } = await query.graph({
entity: "review",
fields: ["id", "customer_id"],
filters: { id },
})
if (!reviews || reviews.length === 0) {
throw new MedusaError(MedusaError.Types.NOT_FOUND, "Review not found")
}
if (reviews[0].customer_id !== customerId) {
throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Not your review")
}
// This bypasses workflow validation
await deleteReviewWorkflow(req.scope).run({
input: { id },
})
return res.status(204).send()
}See [workflows.md](workflows.md#business-logic-and-validation-placement) for the complete pattern of validating ownership in workflow steps.
Pattern: Customer Profile Routes
// api/store/customers/me/wishlist/route.ts
// Automatically protected because it's under /store/customers/me/*
export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
const customerId = req.auth_context.actor_id
const query = req.scope.resolve("query")
// Get customer's wishlist
const { data: wishlists } = await query.graph({
entity: "wishlist",
fields: ["id", "products.*"],
filters: {
customer_id: customerId,
},
})
return res.json({ wishlist: wishlists[0] || null })
}
export async function POST(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
const customerId = req.auth_context.actor_id
const { product_id } = req.validatedBody
// Add product to customer's wishlist
const { result } = await addToWishlistWorkflow(req.scope).run({
input: {
customer_id: customerId,
product_id,
},
})
return res.json({ wishlist: result })
}Pattern: Admin Action Tracking
// api/admin/products/[id]/archive/route.ts
export async function POST(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
const adminUserId = req.auth_context.actor_id
const { id } = req.params
// Archive product and track who did it
const { result } = await archiveProductWorkflow(req.scope).run({
input: {
product_id: id,
archived_by: adminUserId,
archived_at: new Date(),
},
})
const logger = req.scope.resolve("logger")
logger.info(`Product ${id} archived by admin user ${adminUserId}`)
return res.json({ product: result })
}Pattern: Optional Authentication
Some routes may benefit from authentication but don't require it. Use the authenticate middleware with allowUnauthenticated: true:
// api/middlewares.ts
import {
defineMiddlewares,
authenticate,
} from "@medusajs/framework/http"
export default defineMiddlewares({
routes: [
{
matcher: "/store/products/*/reviews",
middlewares: [
authenticate("customer", ["session", "bearer"], {
allowUnauthenticated: true, // Allows access without authentication
})
],
},
],
})// api/store/products/[id]/reviews/route.ts
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const customerId = req.auth_context?.actor_id // May be undefined
const { id } = req.params
const query = req.scope.resolve("query")
// Get all reviews
const { data: reviews } = await query.graph({
entity: "review",
fields: ["id", "rating", "comment", "customer_id"],
filters: {
product_id: id,
},
})
// If authenticated, mark customer's own reviews
if (customerId) {
reviews.forEach(review => {
review.is_own = review.customer_id === customerId
})
}
return res.json({ reviews })
}Frontend Integration
Store (Customer) Authentication
When using the Medusa JS SDK in storefronts:
// Frontend code
import { sdk } from "./lib/sdk"
// Login
await sdk.auth.login("customer", "emailpass", {
email: "customer@example.com",
password: "password",
})
// SDK automatically includes auth headers in subsequent requests
const { customer } = await sdk.store.customer.retrieve()
// Access protected routes
const { orders } = await sdk.store.customer.listOrders()Admin Authentication
When using the Medusa JS SDK in admin applications:
// Admin frontend code
import { sdk } from "./lib/sdk"
// Login
await sdk.auth.login("user", "emailpass", {
email: "admin@example.com",
password: "password",
})
// SDK automatically includes JWT in Authorization header
const { products } = await sdk.admin.product.list()Security Best Practices
1. Use Actor ID from Context
// ✅ GOOD: Uses authenticated context
const customerId = req.auth_context.actor_id
// ❌ BAD: Takes user ID from request
const { customer_id } = req.validatedBody // ❌ Can be spoofed2. Appropriate Authentication Methods
// ✅ GOOD: Admin routes support all methods
authenticate("user", ["session", "bearer", "api-key"])
// ✅ GOOD: Customer routes use session/bearer only
authenticate("customer", ["session", "bearer"])
// ❌ BAD: Customer routes with API key
authenticate("customer", ["api-key"]) // API keys are for admin only3. Don't Expose Sensitive Data
// ✅ GOOD: Filters sensitive fields
export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) {
const customerId = req.auth_context.actor_id
const customer = await getCustomer(customerId)
// Remove sensitive data before sending
delete customer.password_hash
delete customer.metadata?.internal_notes
return res.json({ customer })
}Custom Modules
Contents
- When to Create a Custom Module
- Module Structure
- Creating a Custom Module - Implementation Checklist
- Step 1: Create the Data Model
- Step 2: Create the Service
- Step 3: Export Module Definition
- Step 4: Register in Configuration
- Steps 5-6: Generate and Run Migrations
- Resolving Services from Container
- Auto-Generated CRUD Methods
- Loaders
A module is a reusable package of functionalities related to a single domain or integration. Modules contain data models (database tables) and a service class that provides methods to manage them.
When to Create a Custom Module
- New domain concepts: Brands, wishlists, reviews, loyalty points
- Third-party integrations: ERPs, CMSs, custom services
- Isolated business logic: Features that don't fit existing commerce modules
Module Structure
src/modules/blog/
├── models/
│ └── post.ts # Data model definitions
├── service.ts # Main service class
└── index.ts # Module definition exportCreating a Custom Module - Implementation Checklist
IMPORTANT FOR CLAUDE CODE: When implementing custom modules, use the TodoWrite tool to track your progress through these steps. This ensures you don't miss any critical steps (especially migrations!) and provides visibility to the user.
Create these tasks in your todo list:
- Create data model in src/modules/[name]/models/
- Create service extending MedusaService
- Export module definition in index.ts
- CRITICAL: Register module in medusa-config.ts (do this before using the module)
- CRITICAL: Generate migrations: npx medusa db:generate [module-name] (Never skip!)
- CRITICAL: Run migrations: npx medusa db:migrate (Never skip!)
- Use module service in API routes/workflows
- CRITICAL: Run build to validate implementation (catches type errors and issues)
Step 1: Create the Data Model
// src/modules/blog/models/post.ts
import { model } from "@medusajs/framework/utils"
const Post = model.define("post", {
id: model.id().primaryKey(),
title: model.text(),
content: model.text().nullable(),
published: model.boolean().default(false),
})
// note models automatically get created_at, updated_at and deleted_at added - don't add these explicitly
export default PostData model reference: See data-models.md
Step 2: Create the Service
// src/modules/blog/service.ts
import { MedusaService } from "@medusajs/framework/utils"
import Post from "./models/post"
class BlogModuleService extends MedusaService({
Post,
}) {}
export default BlogModuleServiceThe service extends MedusaService which auto-generates CRUD methods for each data model.
Step 3: Export Module Definition
// src/modules/blog/index.ts
import BlogModuleService from "./service"
import { Module } from "@medusajs/framework/utils"
export const BLOG_MODULE = "blog"
export default Module(BLOG_MODULE, {
service: BlogModuleService,
})⚠️ CRITICAL - Module Name Format:
- Module names MUST be in camelCase
- NEVER use dashes (kebab-case) in module names
- ✅ CORRECT:
"blog","productReview","orderTracking" - ❌ WRONG:
"product-review","order-tracking"(will cause runtime errors)
Example of common mistake:
// ❌ WRONG - dashes will break the module
export const PRODUCT_REVIEW_MODULE = "product-review" // Don't do this!
export default Module("product-review", { service: ProductReviewService })
// ✅ CORRECT - use camelCase
export const PRODUCT_REVIEW_MODULE = "productReview"
export default Module("productReview", { service: ProductReviewService })Why this matters: Medusa's internal module resolution uses property access syntax (e.g., container.resolve("productReview")), and dashes would break this.
Step 4: Register in Configuration
IMPORTANT: You MUST register the module in the configurations BEFORE using it anywhere or generating migrations.
// medusa-config.ts
module.exports = defineConfig({
// ...
modules: [{ resolve: "./src/modules/blog" }],
})Steps 5-6: Generate and Run Migrations
⚠️ CRITICAL - DO NOT SKIP: After creating a module and registering it in medusa-config.ts, you MUST run TWO SEPARATE commands. Without this step, the module's database tables won't exist and you will get runtime errors.
# Step 5: Generate migrations (creates migration files)
# Command format: npx medusa db:generate <module-name>
npx medusa db:generate blog
# Step 6: Run migrations (applies changes to database)
# This command takes NO arguments
npx medusa db:migrate⚠️ CRITICAL: These are TWO separate commands:
- ✅ CORRECT: Run
npx medusa db:generate blogthennpx medusa db:migrate - ❌ WRONG:
npx medusa db:generate blog "create blog module"(no description parameter!) - ❌ WRONG: Combining into one command
Why this matters:
- Migrations create the database tables for your module's data models
- Without migrations, the module service methods (createPosts, listPosts, etc.) will fail
- You must generate migrations BEFORE running them
- This step is REQUIRED before using the module anywhere in your code
Common mistake: Creating a module and immediately trying to use it in a workflow or API route without running migrations first. Always run migrations immediately after registering the module.
Resolving Services from Container
Access your module service in different contexts:
// In API routes
const blogService = req.scope.resolve("blog")
const post = await blogService.createPosts({ title: "Hello World" })
// In workflow steps
const blogService = container.resolve("blog")
const posts = await blogService.listPosts({ published: true })The module name used in Module("blog", ...) becomes the container resolution key.
Auto-Generated CRUD Methods
The service auto-generates methods for each data model:
// Create - pass object or array of objects
const post = await blogService.createPosts({ title: "Hello" })
const posts = await blogService.createPosts([
{ title: "One" },
{ title: "Two" },
])
// Retrieve - by ID, with optional select/relations
const post = await blogService.retrievePost("post_123")
const post = await blogService.retrievePost("post_123", {
select: ["id", "title"],
})
// List - with filters and options
const posts = await blogService.listPosts()
const posts = await blogService.listPosts({ published: true })
const posts = await blogService.listPosts(
{ published: true }, // filters
{ take: 20, skip: 0, order: { created_at: "DESC" } } // options
)
// List with count - returns [records, totalCount]
const [posts, count] = await blogService.listAndCountPosts({ published: true })
// Update - by ID or with selector/data pattern
const post = await blogService.updatePosts({ id: "post_123", title: "Updated" })
const posts = await blogService.updatePosts({
selector: { published: false },
data: { published: true },
})
// Delete - by ID, array of IDs, or filter object
await blogService.deletePosts("post_123")
await blogService.deletePosts(["post_123", "post_456"])
await blogService.deletePosts({ published: false })
// Soft delete / restore
await blogService.softDeletePosts("post_123")
await blogService.restorePosts("post_123")Loaders
Loaders run when the Medusa application starts. Use them to initialize connections, seed data (relevant to the Module), or register resources.
// src/modules/blog/loaders/hello-world.ts
import { LoaderOptions } from "@medusajs/framework/types"
export default async function helloWorldLoader({ container }: LoaderOptions) {
const logger = container.resolve("logger")
logger.info("[BLOG MODULE] Started!")
}
// Export in module definition (src/modules/blog/index.ts)
import helloWorldLoader from "./loaders/hello-world"
export default Module("blog", {
service: BlogModuleService,
loaders: [helloWorldLoader],
})Data Models
Data models represent tables in the database. Use Medusa's Data Model Language (DML) to define them.
Property Types
import { model } from "@medusajs/framework/utils"
const MyModel = model.define("my_model", {
// Primary key (required)
id: model.id().primaryKey(),
// Text
name: model.text(),
description: model.text().nullable(),
// Numbers
quantity: model.number(),
price: model.bigNumber(), // For high precision
// Boolean
is_active: model.boolean().default(true),
// Enum
status: model.enum(["draft", "published", "archived"]).default("draft"),
// Date/Time
published_at: model.dateTime().nullable(),
// JSON (for flexible data)
metadata: model.json().nullable(),
// Array
tags: model.array().nullable(),
})Property Modifiers
model.text() // Required by default
model.text().nullable() // Allow null values
model.text().default("value") // Set default value
model.text().unique() // Unique constraint
model.text().primaryKey() // Set as primary keyRelationships Within a Module
Define relationships between data models in the same module:
// src/modules/blog/models/post.ts
import { model } from "@medusajs/framework/utils"
import { Comment } from "./comment"
export const Post = model.define("post", {
id: model.id().primaryKey(),
title: model.text(),
comments: model.hasMany(() => Comment, {
mappedBy: "post",
}),
})
// src/modules/blog/models/comment.ts
import { model } from "@medusajs/framework/utils"
import { Post } from "./post"
export const Comment = model.define("comment", {
id: model.id().primaryKey(),
content: model.text(),
post: model.belongsTo(() => Post, {
mappedBy: "comments",
}),
})Relationship Types
model.hasMany()- One-to-many (post has many comments)model.belongsTo()- Many-to-one (comment belongs to post)model.hasOne()- One-to-onemodel.manyToMany()- Many-to-many
Automatic Properties
Data models automatically include:
created_at- Creation timestampupdated_at- Last update timestampdeleted_at- Soft delete timestamp
Important: Never add these properties explicitly to your model definitions.
Generate and Run Migrations After Changes
After making changes to a data model, such as adding a property, you MUST generate migrations BEFORE running migrations:
npx medusa db:generate blog
npx medusa db:migrateError Handling in Medusa
Medusa provides the MedusaError class for consistent error responses across your API routes and custom code.
Contents
Using MedusaError
Use MedusaError in API routes, workflows, and custom modules to throw errors that Medusa will automatically format and return to clients:
import { MedusaError } from "@medusajs/framework/utils"
// Throw an error
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
"Product not found"
)Error Types
NOT_FOUND
Use when a requested resource doesn't exist:
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
"Product with ID 'prod_123' not found"
)HTTP Status: 404
INVALID_DATA
Use when request data fails validation or is malformed:
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Email address is invalid"
)HTTP Status: 400
UNAUTHORIZED
Use when authentication is required but not provided:
throw new MedusaError(
MedusaError.Types.UNAUTHORIZED,
"Authentication required to access this resource"
)HTTP Status: 401
NOT_ALLOWED
Use when the user is authenticated but doesn't have permission:
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
"You don't have permission to delete this product"
)HTTP Status: 403
CONFLICT
Use when the operation conflicts with existing data:
throw new MedusaError(
MedusaError.Types.CONFLICT,
"A product with this handle already exists"
)HTTP Status: 409
DUPLICATE_ERROR
Use when trying to create a duplicate resource:
throw new MedusaError(
MedusaError.Types.DUPLICATE_ERROR,
"Email address is already registered"
)HTTP Status: 422
INVALID_STATE
Use when the resource is in an invalid state for the operation:
throw new MedusaError(
MedusaError.Types.INVALID_STATE,
"Cannot cancel an order that has already been fulfilled"
)HTTP Status: 400
Error Response Format
Medusa automatically formats errors into a consistent JSON response:
{
"type": "not_found",
"message": "Product with ID 'prod_123' not found"
}Best Practices
1. Use Specific Error Types
Choose the most appropriate error type for the situation:
// ✅ GOOD: Uses specific error types
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const { id } = req.params
const query = req.scope.resolve("query")
const { data } = await query.graph({
entity: "product",
fields: ["id", "title"],
filters: { id },
})
if (!data || data.length === 0) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Product with ID '${id}' not found`
)
}
return res.json({ product: data[0] })
}
// ❌ BAD: Uses generic error
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const { id } = req.params
const query = req.scope.resolve("query")
const { data } = await query.graph({
entity: "product",
fields: ["id", "title"],
filters: { id },
})
if (!data || data.length === 0) {
throw new Error("Product not found") // Generic error
}
return res.json({ product: data[0] })
}2. Provide Clear Error Messages
Error messages should be descriptive and help users understand what went wrong:
// ✅ GOOD: Clear, specific message
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Cannot create product: title must be at least 3 characters long"
)
// ❌ BAD: Vague message
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Invalid input"
)3. Include Context in Error Messages
// ✅ GOOD: Includes relevant context
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Product with ID '${productId}' not found`
)
// ✅ GOOD: Includes field name
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Invalid email format: '${email}'`
)4. Handle Workflow Errors
When calling workflows from API routes, catch and transform errors:
// ✅ GOOD: Catches and transforms workflow errors
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const { data } = req.validatedBody
try {
const { result } = await myWorkflow(req.scope).run({
input: { data },
})
return res.json({ result })
} catch (error) {
// Transform workflow errors into API errors
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Failed to create resource: ${error.message}`
)
}
}5. Use Validation Middleware
Let validation middleware handle input validation errors:
// ✅ GOOD: Middleware handles validation
// middlewares.ts
const MySchema = z.object({
email: z.string().email("Invalid email address"),
age: z.number().min(18, "Must be at least 18 years old"),
})
export const myMiddlewares: MiddlewareRoute[] = [
{
matcher: "/store/my-route",
method: "POST",
middlewares: [validateAndTransformBody(MySchema)],
},
]
// route.ts - No need to validate again
export async function POST(req: MedusaRequest, res: MedusaResponse) {
const { email, age } = req.validatedBody // Already validated
// Your logic here
}Frontend SDK Integration
Contents
- Frontend SDK Pattern
- Locating the SDK
- Using sdk.client.fetch()
- React Query Pattern
- Query Key Best Practices
- Error Handling
- Optimistic Updates
This guide covers how to integrate Medusa custom API routes with frontend applications using the Medusa SDK and React Query.
Note: API routes are also referred to as "endpoints" - these terms are interchangeable.
Frontend SDK Pattern
Locating the SDK
IMPORTANT: Never hardcode SDK import paths. Always locate where the SDK is instantiated in the project first.
Look for @medusajs/js-sdk
The SDK instance is typically exported as sdk:
import { sdk } from "[LOCATE IN PROJECT]"Using sdk.client.fetch()
⚠️ CRITICAL: ALWAYS use the Medusa JS SDK for ALL API requests - NEVER use regular fetch()
Why this is critical:
- Store API routes require the publishable API key in headers
- Admin API routes require authentication headers
- Regular fetch() without these headers will cause errors
- The SDK automatically handles all required headers for you
When to use what:
- Existing endpoints (built-in Medusa routes): Use existing SDK methods like
sdk.store.product.list(),sdk.admin.order.retrieve() - Custom endpoints (your custom API routes): Use
sdk.client.fetch()for custom routes
⚠️ CRITICAL: The SDK handles JSON serialization automatically. NEVER use JSON.stringify() on the body.
Call custom API routes using the SDK:
import { sdk } from "[LOCATE SDK INSTANCE IN PROJECT]"
// ✅ CORRECT - Pass object directly
const result = await sdk.client.fetch("/store/my-route", {
method: "POST",
body: {
email: "user@example.com",
name: "John Doe",
},
})
// ❌ WRONG - Don't use JSON.stringify
const result = await sdk.client.fetch("/store/my-route", {
method: "POST",
body: JSON.stringify({ // ❌ DON'T DO THIS!
email: "user@example.com",
}),
})Key points:
- The SDK handles JSON serialization automatically - just pass plain objects
- NEVER use JSON.stringify() - this will break the request
- No need to set Content-Type headers - SDK adds them
- Session/JWT authentication is handled automatically
- Publishable API key is automatically added
Built-in Endpoints vs Custom Endpoints
⚠️ CRITICAL: Use the appropriate SDK method based on endpoint type
import { sdk } from "[LOCATE SDK INSTANCE IN PROJECT]"
// ✅ CORRECT - Built-in endpoint: Use existing SDK method
const products = await sdk.store.product.list({
limit: 10,
offset: 0
})
// ✅ CORRECT - Custom endpoint: Use sdk.client.fetch()
const reviews = await sdk.client.fetch("/store/products/prod_123/reviews")
// ❌ WRONG - Using regular fetch for ANY endpoint
const products = await fetch("http://localhost:9000/store/products")
// ❌ Error: Missing publishable API key header!
// ❌ WRONG - Using regular fetch for custom endpoint
const reviews = await fetch("http://localhost:9000/store/products/prod_123/reviews")
// ❌ Error: Missing publishable API key header!
// ❌ WRONG - Using sdk.client.fetch() for built-in endpoint when SDK method exists
const products = await sdk.client.fetch("/store/products")
// ❌ Less type-safe than using sdk.store.product.list()Why this matters:
- Store routes require
x-publishable-api-keyheader - SDK adds it automatically - Admin routes require
Authorizationand session cookie headers - SDK adds them automatically - Regular fetch() doesn't include these headers → API returns authentication/authorization errors
- Using existing SDK methods provides better type safety and autocomplete
React Query Pattern
Use useQuery for GET requests and useMutation for POST/DELETE:
import { sdk } from "[LOCATE SDK INSTANCE IN PROJECT]"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
function MyComponent({ userId }: { userId: string }) {
const queryClient = useQueryClient()
// GET request - fetching data
const { data, isLoading } = useQuery({
queryKey: ["my-data", userId],
queryFn: () => sdk.client.fetch(`/store/my-route?userId=${userId}`),
enabled: !!userId,
})
// POST request - mutation with cache invalidation
const mutation = useMutation({
mutationFn: (input: { email: string }) =>
sdk.client.fetch("/store/my-route", { method: "POST", body: input }),
onSuccess: () => {
// Invalidate and refetch related queries
queryClient.invalidateQueries({ queryKey: ["my-data"] })
},
})
if (isLoading) return <p>Loading...</p>
return (
<div>
<p>{data?.title}</p>
<button
onClick={() => mutation.mutate({ email: "test@example.com" })}
disabled={mutation.isPending}
>
{mutation.isPending ? "Loading..." : "Submit"}
</button>
{mutation.isError && <p>Error occurred</p>}
</div>
)
}Key states: isLoading, isPending, isSuccess, isError, error
Query Key Best Practices
Structure query keys for effective cache management:
// Good: Hierarchical structure
queryKey: ["products", productId]
queryKey: ["products", "list", { page, filters }]
// Invalidate all product queries
queryClient.invalidateQueries({ queryKey: ["products"] })
// Invalidate specific product
queryClient.invalidateQueries({ queryKey: ["products", productId] })Error Handling
Handle API errors gracefully:
const mutation = useMutation({
mutationFn: (input) => sdk.client.fetch("/store/my-route", {
method: "POST",
body: input
}),
onError: (error) => {
console.error("Mutation failed:", error)
// Show error message to user
},
})
// In component
{mutation.isError && (
<p className="error">
{mutation.error?.message || "An error occurred"}
</p>
)}Optimistic Updates
Update UI immediately before server confirms:
const mutation = useMutation({
mutationFn: (newItem) =>
sdk.client.fetch("/store/items", { method: "POST", body: newItem }),
onMutate: async (newItem) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ["items"] })
// Snapshot previous value
const previousItems = queryClient.getQueryData(["items"])
// Optimistically update
queryClient.setQueryData(["items"], (old) => [...old, newItem])
// Return context with snapshot
return { previousItems }
},
onError: (err, newItem, context) => {
// Rollback on error
queryClient.setQueryData(["items"], context.previousItems)
},
onSettled: () => {
// Refetch after mutation
queryClient.invalidateQueries({ queryKey: ["items"] })
},
})Module Links
Contents
- When to Use Links
- Implementing Module Links - Workflow Checklist
- Step 1: Defining a Link
- Step 2: Link Configuration Options
- List Links (One-to-Many)
- Delete Cascades
- Step 3: Sync Links (Run Migrations)
- Step 4: Managing Links
- Step 5: Querying Linked Data
- Advanced: Link with Custom Columns
Module links create associations between data models in different modules while maintaining module isolation. Use links to connect your custom models to Commerce Module models (products, customers, orders, etc.).
When to Use Links
- Extend commerce entities: Add brands to products, wishlists to customers
- Cross-module associations: Connect custom modules to each other
- Maintain isolation: Keep modules independent and reusable
Implementing Module Links - Workflow Checklist
IMPORTANT FOR CLAUDE CODE: When implementing module links, use the TodoWrite tool to track your progress through these steps. This ensures you don't miss any critical steps and provides visibility to the user.
Create these tasks in your todo list:
- Optional: Add linked ID in custom data model (if one-to-one or one-to-many)
- Define the link in src/links/
- Configure list or delete cascade options if needed
- CRITICAL: Run migrations: npx medusa db:migrate (Never skip this step!)
- Create links in code using link.create() or createRemoteLinkStep
- Query linked data using query.graph()
- CRITICAL: Run build to validate implementation (catches type errors and issues)
Optional: Add Linked ID in Custom Data Model
Add the ID of a linked data model in the custom data model if the custom data model belongs to it or extends it. Otherwise, skip this step.
For example, add ID of customer and product to custom product review model:
import { model } from "@medusajs/framework/utils"
const Review = model.define("review", {
// other properties...
// ID of linked customer
customer_id: model.text(),
// ID of linked product
product_id: model.text()
})
export default ReviewStep 1: Defining a Link
⚠️ CRITICAL RULE: Create ONE link definition per file. Do NOT export an array of links from a single file.
Create link files in src/links/:
// ✅ CORRECT - src/links/product-brand.ts (one link per file)
import { defineLink } from "@medusajs/framework/utils"
import ProductModule from "@medusajs/medusa/product"
import BrandModule from "../modules/brand"
export default defineLink(
ProductModule.linkable.product,
BrandModule.linkable.brand
)If one model links to multiple others, create multiple files:
// ✅ CORRECT - src/links/review-product.ts
export default defineLink(
ReviewModule.linkable.review,
ProductModule.linkable.product
)
// ✅ CORRECT - src/links/review-customer.ts
export default defineLink(
ReviewModule.linkable.review,
CustomerModule.linkable.customer
)
// ❌ WRONG - Don't export array of links from one file
export default [
defineLink(ReviewModule.linkable.review, ProductModule.linkable.product),
defineLink(ReviewModule.linkable.review, CustomerModule.linkable.customer),
] // This doesn't work!IMPORTANT: The .linkable property is automatically added to all modules by Medusa. You do NOT need to add .linkable() or any linkable definition to your data models. Simply use ModuleName.linkable.modelName when defining links.
For example, if you have a Review data model in a ReviewModule:
- ✅ CORRECT:
ReviewModule.linkable.review(works automatically) - ❌ WRONG: Adding
.linkable()method to the Review model definition (not needed, causes errors)
⚠️ NEXT STEP: After defining a link, you MUST immediately proceed to Step 3 to run migrations (npx medusa db:migrate). Do not skip this step!
Step 2: Link Configuration Options
List Links (One-to-Many)
Allow multiple records to link to one record:
// A brand can have many products
export default defineLink(
{
linkable: ProductModule.linkable.product,
isList: true,
},
BrandModule.linkable.brand
)Delete Cascades
Automatically delete links when a record is deleted:
export default defineLink(ProductModule.linkable.product, {
linkable: BrandModule.linkable.brand,
deleteCascade: true,
})Step 3: Sync Links (Run Migrations)
⚠️ CRITICAL - DO NOT SKIP: After defining links, you MUST run migrations to sync the link to the database. Without this step, the link will not work and you will get runtime errors.
npx medusa db:migrateWhy this matters:
- Links create database tables that store the relationships between modules
- Without migrations, these tables don't exist and link operations will fail
- This step is REQUIRED before creating any links in code or querying linked data
Common mistake: Defining a link in src/links/ and immediately trying to use it in a workflow or query without running migrations first. Always run migrations immediately after defining a link.
Step 4: Managing Links
⚠️ CRITICAL - Link Order (Direction): When creating or dismissing links, the order of modules MUST match the order in defineLink(). Mismatched order causes runtime errors.
// Example link definition: product FIRST, then brand
export default defineLink(
ProductModule.linkable.product,
BrandModule.linkable.brand
)In Workflow Composition Functions
To create a link between records in workflow composition functions, use the createRemoteLinkStep:
import { Modules } from "@medusajs/framework/utils"
import { createRemoteLinkStep } from "@medusajs/medusa/core-flows"
import {
createWorkflow,
transform,
} from "@medusajs/framework/workflows-sdk"
const BRAND_MODULE = "brand"
export const myWorkflow = createWorkflow(
"my-workflow",
function (input) {
// ...
// ✅ CORRECT - Order matches defineLink (product first, then brand)
const linkData = transform({ input }, ({ input }) => {
return [
{
[Modules.PRODUCT]: {
product_id: input.product_id,
},
[BRAND_MODULE]: {
brand_id: input.brand_id,
},
},
]
})
createRemoteLinkStep(linkData)
// ...
}
)
// ❌ WRONG - Order doesn't match defineLink
const linkData = transform({ input }, ({ input }) => {
return [
{
[BRAND_MODULE]: {
brand_id: input.brand_id,
},
[Modules.PRODUCT]: {
product_id: input.product_id,
},
},
]
}) // Runtime error: link direction mismatch!To dismiss (remove) a link between records in workflow composition functions, use the dismissRemoteLinkStep:
import { Modules } from "@medusajs/framework/utils"
import { dismissRemoteLinkStep } from "@medusajs/medusa/core-flows"
import {
createWorkflow,
transform,
} from "@medusajs/framework/workflows-sdk"
const BRAND_MODULE = "brand"
export const myWorkflow = createWorkflow(
"my-workflow",
function (input) {
// ...
// Order MUST match defineLink (product first, then brand)
const linkData = transform({ input }, ({ input }) => {
return [
{
[Modules.PRODUCT]: {
product_id: input.product_id,
},
[BRAND_MODULE]: {
brand_id: input.brand_id,
},
},
]
})
dismissRemoteLinkStep(linkData)
// ...
}
)Outside Workflows
Outside workflows or in workflow steps, use the link utility to create and manage links between records. Order MUST match `defineLink()` here too:
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
// In an API route or workflow step
const link = container.resolve(ContainerRegistrationKeys.LINK)
const BRAND_MODULE = "brand"
// ✅ CORRECT - Create a link (order matches defineLink: product first, then brand)
await link.create({
[Modules.PRODUCT]: { product_id: "prod_123" },
[BRAND_MODULE]: { brand_id: "brand_456" },
})
// ✅ CORRECT - Dismiss (remove) a link (same order: product first, then brand)
await link.dismiss({
[Modules.PRODUCT]: { product_id: "prod_123" },
[BRAND_MODULE]: { brand_id: "brand_456" },
})
// ❌ WRONG - Order doesn't match defineLink
await link.create({
[BRAND_MODULE]: { brand_id: "brand_456" },
[Modules.PRODUCT]: { product_id: "prod_123" },
}) // Runtime error: link direction mismatch!Step 5: Querying Linked Data
Using query.graph() - Retrieve Linked Data
Use query.graph() to fetch data across linked modules. Note: query.graph() can retrieve linked data but cannot filter by properties of linked modules (data models in separate modules).
const query = container.resolve("query")
// ✅ Get products with their linked brands (no cross-module filtering)
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "brand.*"], // brand.* fetches linked brand data
filters: {
id: "prod_123", // ✅ Filter by product properties only
},
})
// ✅ Get brands with their linked products
const { data: brands } = await query.graph({
entity: "brand",
fields: ["id", "name", "products.*"],
})
// ❌ DOES NOT WORK: Cannot filter products by linked brand properties
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "brand.*"],
filters: {
brand: {
name: "Nike" // ❌ Fails: brand is in a different module
}
}
})Using query.index() - Filter Across Linked Modules
To filter by properties of linked modules (separate modules with module links), use query.index() from the Index Module:
const query = container.resolve("query")
// ✅ Filter products by linked brand name using Index Module
const { data: products } = await query.index({
entity: "product",
fields: ["*", "brand.*"],
filters: {
brand: {
name: "Nike" // ✅ Works with Index Module!
}
}
})Key Distinction:
- Same module relations (e.g., Product → ProductVariant): Use
query.graph()- filtering works ✅ - Different module links (e.g., Product → Brand): Use
query.index()for filtering ✅
Index Module Requirements: 1. Install @medusajs/index package 2. Add to medusa-config.ts 3. Enable MEDUSA_FF_INDEX_ENGINE=true in .env 4. Run npx medusa db:migrate 5. Mark properties as filterable in link definition:
// src/links/product-brand.ts
defineLink(
{ linkable: ProductModule.linkable.product, isList: true },
{ linkable: BrandModule.linkable.brand, filterable: ["id", "name"] }
)See the Querying Data reference for complete details on both methods.
Advanced: Link with Custom Columns
Add extra data to the link table:
export default defineLink(
ProductModule.linkable.product,
BrandModule.linkable.brand,
{
database: {
extraColumns: {
featured: {
type: "boolean",
defaultValue: "false",
},
},
},
}
)Set custom column values when creating links:
await link.create({
product: { product_id: "prod_123" },
brand: { brand_id: "brand_456" },
data: { featured: true },
})Querying Data in Medusa
Medusa's Query API (query.graph()) is the primary way to retrieve data, especially across modules. It provides a flexible, performant way to query entities with relations and filters.
Contents
- When to Use Query vs Module Services
- Basic Query Structure
- In Workflows vs Outside Workflows
- Field Selection
- Filtering
- Important Filtering Limitation
- Pagination
- Querying Linked Data
- Option 1: query.graph() - Retrieve Linked Data Without Cross-Module Filters
- Option 2: query.index() - Filter Across Linked Modules (Index Module)
- Validation with throwIfKeyNotFound
- Performance Best Practices
When to Use Query vs Module Services
⚠️ USE QUERY FOR:
- ✅ Retrieving data across modules (products with linked brands, orders with customers)
- ✅ Reading data with linked entities
- ✅ Complex queries with multiple relations
- ✅ Storefront and admin data retrieval
⚠️ USE MODULE SERVICES FOR:
- ✅ Retrieving data within a single module (products with variants - same module)
- ✅ Using
listAndCountfor pagination within one module - ✅ Mutations (always use module services or workflows)
Examples:
// ✅ GOOD: Query for cross-module data
const { data } = await query.graph({
entity: "product",
fields: ["id", "title", "brand.*"], // brand is in different module
})
// ✅ GOOD: Module service for single module
const [products, count] = await productService.listAndCountProducts(
{ status: "active" },
{ take: 10, skip: 0 }
)Basic Query Structure
const query = req.scope.resolve("query")
const { data } = await query.graph({
entity: "entity_name", // The entity to query
fields: ["id", "name"], // Fields to retrieve
filters: { status: "active" }, // Filter conditions
pagination: { // Optional pagination
take: 10,
skip: 0,
},
})In Workflows vs Outside Workflows
Outside Workflows (API Routes, Subscribers, Scheduled Jobs)
// In API routes
const query = req.scope.resolve("query")
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title"],
})
// In subscribers/scheduled jobs
const query = container.resolve("query")
const { data: customers } = await query.graph({
entity: "customer",
fields: ["id", "email"],
})In Workflows
Use useQueryGraphStep within workflow composition functions:
import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"
import { useQueryGraphStep } from "@medusajs/medusa/core-flows"
const myWorkflow = createWorkflow(
"my-workflow",
function (input) {
const { data: products } = useQueryGraphStep({
entity: "product",
fields: ["id", "title"],
filters: {
id: input.product_id,
},
})
return new WorkflowResponse({ products })
}
)Field Selection
Basic Fields
const { data } = await query.graph({
entity: "product",
fields: ["id", "title", "description"],
})Nested Relations
Use dot notation to include related entities:
const { data } = await query.graph({
entity: "product",
fields: [
"id",
"title",
"variants.*", // All fields from variants
"variants.sku", // Specific variant field
"category.id",
"category.name",
],
})Performance Tip
⚠️ IMPORTANT: Only retrieve fields and relations you'll actually use. Avoid using * to select all fields or retrieving all fields of a relation unnecessarily.
// ❌ BAD: Retrieves all fields (inefficient)
fields: ["*"]
// ❌ BAD: Retrieves all product fields (might be many)
fields: ["product.*"]
// ✅ GOOD: Only retrieves needed fields
fields: ["id", "title", "product.id", "product.title"]Filtering
Exact Match
filters: {
email: "user@example.com"
}Multiple Values (IN operator)
filters: {
id: ["id1", "id2", "id3"]
}Range Queries
filters: {
created_at: {
$gte: startDate, // Greater than or equal
$lte: endDate, // Less than or equal
}
}Text Search (LIKE)
filters: {
name: {
$like: "%search%" // Contains "search"
}
}
// Starts with
filters: {
name: {
$like: "search%"
}
}
// Ends with
filters: {
name: {
$like: "%search"
}
}Not Equal
filters: {
status: {
$ne: "deleted"
}
}Multiple Conditions
filters: {
status: "active",
created_at: {
$gte: new Date("2024-01-01"),
},
price: {
$gte: 10,
$lte: 100,
},
}Filtering Nested Relations (Same Module)
To filter by fields in nested relations within the same module, use object notation:
// Product and ProductVariant are in the same module (Product Module)
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "variants.*"],
filters: {
variants: {
sku: "ABC1234" // ✅ Works: variants are in same module as product
}
}
})Important Filtering Limitation
⚠️ CRITICAL: With query.graph(), you CANNOT filter by fields from linked data models in different modules. The query.graph() method only supports filters on data models within the same module.
What This Means
- Same Module (✅ Can filter with
query.graph()): Product and ProductVariant, Order and LineItem, Cart and CartItem - Different Modules (❌ Cannot filter with
query.graph()): Product and Brand (custom), Product and Customer, Review and Product - Different Modules (✅ Can filter with
query.index()): Any linked modules when using the Index Module
Example: Cannot Filter Products by Linked Brand with query.graph()
// ❌ THIS DOES NOT WORK with query.graph()
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "brand.*"],
filters: {
"brand.name": "Nike" // ❌ Cannot filter by linked module field
}
})
// ❌ THIS ALSO DOES NOT WORK with query.graph()
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "brand.*"],
filters: {
brand: {
name: "Nike" // ❌ Still doesn't work - brand is in different module
}
}
})Solution 1: Use query.index() with Index Module (Recommended)
✅ BEST APPROACH: Use the Index Module to filter across linked modules efficiently at the database level:
// ✅ CORRECT: Use query.index() to filter products by linked brand
const { data: products } = await query.index({
entity: "product",
fields: ["*", "brand.*"],
filters: {
brand: {
name: "Nike" // ✅ Works with Index Module!
}
}
})Why this is best:
- Database-level filtering (most efficient)
- Supports pagination properly
- Only retrieves the data you need
- Designed specifically for cross-module filtering
Requirements:
- Index Module must be installed and configured
- Link must have
filterableproperties defined - See Querying Linked Data section for setup details
Solution 2: Query from Other Side
✅ GOOD ALTERNATIVE: Query the linked module and filter on it directly using query.graph():
// ✅ CORRECT: Query brands and get their products
const { data: brands } = await query.graph({
entity: "brand",
fields: ["id", "name", "products.*"],
filters: {
name: "Nike" // ✅ Filter on brand directly
}
})
// Access Nike products
const nikeProducts = brands[0]?.products || []Use this when:
- You don't have the Index Module set up
- The "other side" of the link makes sense as the primary entity
- You need a quick solution without additional setup
Solution 3: Filter After Query (Least Efficient)
⚠️ LAST RESORT: Query all data with query.graph(), then filter in JavaScript:
// Get all products with brands
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "brand.*"],
})
// Filter in JavaScript after query
const nikeProducts = products.filter(p => p.brand?.name === "Nike")Only use this when:
- Dataset is very small (< 100 records)
- Index Module is not available
- Querying from the other side doesn't make sense
- You need a temporary solution
Avoid because:
- Fetches unnecessary data from database
- Inefficient for large datasets
- No pagination support at database level
- Uses more memory and network bandwidth
More Examples
Example: Approved Reviews for a Specific Product
When you need to filter linked data by its own properties, you have multiple options:
// ❌ WRONG: Cannot filter linked reviews from product query with query.graph()
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "reviews.*"],
filters: {
id: productId,
reviews: {
status: "approved" // ❌ Doesn't work - reviews is linked module
}
}
})
// ❌ ALSO WRONG: Filtering in JavaScript is inefficient
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "reviews.*"],
filters: { id: productId }
})
const approvedReviews = products[0].reviews.filter(r => r.status === "approved") // ❌ Client-side filter
// ✅ OPTION 1 (BEST): Use Index Module to filter cross-module
const { data: products } = await query.index({
entity: "product",
fields: ["*", "reviews.*"],
filters: {
id: productId,
reviews: {
status: "approved" // ✅ Works with Index Module!
}
}
})
// ✅ OPTION 2 (GOOD): Query reviews directly with filters
const { data: reviews } = await query.graph({
entity: "review",
fields: ["id", "rating", "comment", "product.*"],
filters: {
product_id: productId, // Filter by product
status: "approved" // Filter by review status - both in same query!
}
})Why Option 1 (Index Module) is best:
- Database-level filtering across modules
- Returns data in the structure you expect (product with reviews)
- Supports pagination properly
- Only retrieves the data you need
Why Option 2 (query from other side) is good:
- No Index Module setup required
- Still uses database filtering
- Works well when the "other side" is the logical primary entity
Example: Reviews for Active Products (Cross-Module)
// ❌ WRONG: Cannot filter by linked module with query.graph()
const { data } = await query.graph({
entity: "review",
fields: ["id", "rating", "product.*"],
filters: {
product: {
status: "active" // Doesn't work - product is linked module
}
}
})
// ✅ OPTION 1 (BEST): Use Index Module
const { data: reviews } = await query.index({
entity: "review",
fields: ["*", "product.*"],
filters: {
product: {
status: "active" // ✅ Works with Index Module!
}
}
})
// ✅ OPTION 2 (GOOD): Query from the other side
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "reviews.*"],
filters: { status: "active" }
})
// Flatten reviews if needed
const reviews = products.flatMap(p => p.reviews)Example: Products with Variants (Same Module - Works!)
// ✅ CORRECT: Product and variants are in same module (Product Module)
// Use query.graph() - no need for Index Module
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "variants.*"],
filters: {
variants: {
inventory_quantity: {
$gte: 10 // ✅ Works: both in Product Module
}
}
}
})Pagination
Basic Pagination
const { data, metadata } = await query.graph({
entity: "product",
fields: ["id", "title"],
pagination: {
skip: 0, // Offset
take: 10, // Limit
},
})
// metadata.count contains total count
console.log(`Total: ${metadata.count}`)With Ordering
const { data } = await query.graph({
entity: "product",
fields: ["id", "title", "created_at"],
pagination: {
skip: 0,
take: 10,
order: {
created_at: "DESC", // Newest first
},
},
})Multiple Order Fields
pagination: {
order: {
status: "ASC",
created_at: "DESC",
}
}Querying Linked Data
When entities are linked via module links, you have two options depending on your filtering needs:
Option 1: query.graph() - Retrieve Linked Data Without Cross-Module Filters
Use `query.graph()` when:
- ✅ Retrieving linked data without filtering by linked module properties
- ✅ Filtering only by properties in the primary entity's module
- ✅ You want to include related data in the response
Limitations:
- ❌ CANNOT filter by properties of linked modules (data models in separate modules)
- ✅ CAN filter by properties of relations in the same module (e.g., product.variants)
// ✅ WORKS: Get products with their linked brands (no cross-module filtering)
const { data: products } = await query.graph({
entity: "product",
fields: [
"id",
"title",
"brand.*", // All brand fields
],
filters: {
id: "prod_123", // ✅ Filter by product property (same module)
},
})
// Access linked data
console.log(products[0].brand.name)
// ✅ WORKS: Filter by same-module relation (product and variants are in Product Module)
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "variants.*"],
filters: {
variants: {
sku: "ABC1234" // ✅ Works: variants are in same module as product
}
}
})
// ❌ DOES NOT WORK: Cannot filter products by linked brand name
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "brand.*"],
filters: {
brand: {
name: "Nike" // ❌ Fails: brand is in a different module
}
}
})Reverse Query (From Link to Original):
// Get brands with their linked products
const { data: brands } = await query.graph({
entity: "brand",
fields: [
"id",
"name",
"products.*", // All linked products
],
})
// Access linked products
brands[0].products.forEach(product => {
console.log(product.title)
})Option 2: query.index() - Filter Across Linked Modules (Index Module)
Use `query.index()` when:
- ✅ You need to filter data by properties of linked modules (separate modules with module links)
- ✅ Filtering by custom data model properties linked to Commerce Module entities
- ✅ Complex cross-module queries requiring efficient database-level filtering
Key Distinction:
- Same module relations (e.g., Product → ProductVariant): Use
query.graph()✅ - Different module links (e.g., Product → Brand, Product → Review): Use
query.index()✅
When to Use query.index()
The Index Module solves the fundamental limitation of query.graph(): you cannot filter one module's data by another module's linked properties using query.graph().
Examples of when you need query.index():
- Filter products by brand name (Product Module → Brand Module)
- Filter products by review ratings (Product Module → Review Module)
- Filter customers by custom loyalty tier (Customer Module → Loyalty Module)
- Any scenario where you need to filter by properties of a linked data model in a different module
Setup Requirements
Before using query.index(), ensure the Index Module is configured:
1. Install the Index Module:
npm install @medusajs/index2. Add to `medusa-config.ts`:
module.exports = defineConfig({
modules: [
{
resolve: "@medusajs/index",
},
],
})3. Enable the feature flag in `.env`:
MEDUSA_FF_INDEX_ENGINE=true4. Run migrations:
npx medusa db:migrate5. Mark linked properties as filterable in your link definition:
// src/links/product-brand.ts
defineLink(
{ linkable: ProductModule.linkable.product, isList: true },
{ linkable: BrandModule.linkable.brand, filterable: ["id", "name"] }
)The filterable property marks which fields can be queried across modules.
6. Start the application to trigger data ingestion into the Index Module.
Using query.index()
const query = req.scope.resolve("query")
// ✅ CORRECT: Filter products by linked brand name using Index Module
const { data: products } = await query.index({
entity: "product",
fields: ["*", "brand.*"],
filters: {
brand: {
name: "Nike", // ✅ Works with Index Module!
},
},
})
// ✅ CORRECT: Filter products by review ratings
const { data: products } = await query.index({
entity: "product",
fields: ["id", "title", "reviews.*"],
filters: {
reviews: {
rating: {
$gte: 4, // Products with reviews rated 4 or higher
},
},
},
})query.index() Features
Pagination:
const { data: products } = await query.index({
entity: "product",
fields: ["*", "brand.*"],
filters: {
brand: { name: "Nike" },
},
pagination: {
take: 20,
skip: 0,
},
})Advanced Filters:
const { data: products } = await query.index({
entity: "product",
fields: ["*", "brand.*"],
filters: {
brand: {
name: {
$like: "%Acme%", // LIKE operator
},
},
status: {
$ne: "deleted", // Not equal
},
},
})query.graph() vs query.index() Decision Tree
Need to filter by linked module properties?
├─ No → Use query.graph()
│ └─ Faster, simpler, works for most queries
│
└─ Yes → Are the entities in the same module or different modules?
├─ Same module (e.g., product.variants) → Use query.graph()
│ └─ Example: Product and ProductVariant both in Product Module
│
└─ Different modules (e.g., product → brand) → Use query.index()
└─ Example: Product (Product Module) → Brand (Custom Module)
└─ Requires Index Module setup and filterable propertiesImportant Notes
- Performance: The Index Module pre-ingests data on application startup, enabling efficient cross-module filtering
- Data Freshness: Data is synced automatically, but there may be a brief delay after mutations
- Fallback: If you don't need filtering,
query.graph()is sufficient and more straightforward - Module Relations: Always use
query.graph()for same-module relations (product → variants, order → line items)
Validation with throwIfKeyNotFound
Use throwIfKeyNotFound to validate that a record exists before performing operations:
// Outside workflows
const query = req.scope.resolve("query")
const { data } = await query.graph({
entity: "product",
fields: ["id", "title"],
filters: {
id: productId,
},
}, {
throwIfKeyNotFound: true, // Throws if product doesn't exist
})
// If we get here, product exists
const product = data[0]// In workflows
const { data: products } = useQueryGraphStep({
entity: "product",
fields: ["id", "title"],
filters: {
id: input.product_id,
},
options: {
throwIfKeyNotFound: true, // Throws if product doesn't exist
},
})When to use:
- ✅ Before updating or deleting a record
- ✅ When the record MUST exist for the operation to continue
- ✅ To avoid manual existence checks
// ❌ BAD: Manual check
const { data } = await query.graph({ /* ... */ })
if (!data || data.length === 0) {
throw new MedusaError(MedusaError.Types.NOT_FOUND, "Product not found")
}
// ✅ GOOD: Let query handle it
const { data } = await query.graph(
{ /* ... */ },
{ throwIfKeyNotFound: true }
)Performance Best Practices
1. Only Query What You Need
⚠️ CRITICAL: Always specify only the fields you'll use. Avoid using * or querying unnecessary relations.
// ❌ BAD: Retrieves everything (slow, wasteful)
fields: ["*"]
// ✅ GOOD: Only needed fields (fast)
fields: ["id", "title", "price"]2. Limit Relation Depth
There's no hard limit on relation depth, but deeper queries are slower. Only include relations you'll actually use.
// ❌ BAD: Unnecessary depth
fields: [
"id",
"title",
"variants.*",
"variants.product.*", // Circular, unnecessary
"variants.prices.*",
"variants.prices.currency.*", // Probably don't need all currency fields
]
// ✅ GOOD: Appropriate depth
fields: [
"id",
"title",
"variants.id",
"variants.sku",
"variants.prices.amount",
"variants.prices.currency_code",
]3. Use Pagination for Large Result Sets
// ✅ GOOD: Paginated query
const { data, metadata } = await query.graph({
entity: "product",
fields: ["id", "title"],
pagination: {
take: 50, // Don't retrieve thousands of records at once
skip: 0,
},
})4. Filter Early
Apply filters to reduce the data set before retrieving fields and relations:
// ✅ GOOD: Filters reduce result set first
const { data } = await query.graph({
entity: "product",
fields: ["id", "title", "variants.*"],
filters: {
status: "published",
created_at: {
$gte: lastWeek,
},
},
})5. Use Specific Queries for Different Use Cases
// ✅ For listings (minimal fields)
const { data: listings } = await query.graph({
entity: "product",
fields: ["id", "title", "thumbnail", "price"],
})
// ✅ For detail pages (more fields)
const { data: details } = await query.graph({
entity: "product",
fields: [
"id",
"title",
"description",
"thumbnail",
"images.*",
"variants.*",
"variants.prices.*",
],
filters: { id: productId },
})Common Patterns
Pattern: List with Search
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const query = req.scope.resolve("query")
const { q } = req.validatedQuery
const filters: any = {}
if (q) {
filters.title = { $like: `%${q}%` }
}
const { data: products } = await query.graph({
entity: "product",
fields: ["id", "title", "thumbnail"],
filters,
...req.queryConfig, // Uses request query config
})
return res.json({ products })
}Pattern: Retrieve with Validation
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const query = req.scope.resolve("query")
const { id } = req.params
// Throws 404 if product doesn't exist
const { data } = await query.graph({
entity: "product",
fields: ["id", "title", "description", "variants.*"],
filters: { id },
}, {
throwIfKeyNotFound: true,
})
return res.json({ product: data[0] })
}Pattern: Query with Relations and Filters
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const query = req.scope.resolve("query")
const { category_id } = req.validatedQuery
const { data: products } = await query.graph({
entity: "product",
fields: [
"id",
"title",
"thumbnail",
"variants.id",
"variants.prices.amount",
"category.name",
],
filters: {
category_id,
status: "published",
},
pagination: {
take: 20,
skip: 0,
},
})
return res.json({ products })
}Pattern: Count Records
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const query = req.scope.resolve("query")
const { data, metadata } = await query.graph({
entity: "product",
fields: ["id"], // Minimal fields for counting
filters: {
status: "published",
},
})
return res.json({
count: metadata.count,
})
}Pattern: Recent Items
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const query = req.scope.resolve("query")
const { data: recentProducts } = await query.graph({
entity: "product",
fields: ["id", "title", "created_at"],
pagination: {
take: 10,
skip: 0,
order: {
created_at: "DESC", // Newest first
},
},
})
return res.json({ products: recentProducts })
}Scheduled Jobs
Scheduled jobs are asynchronous functions that run automatically at specified intervals during the Medusa application's runtime. Use them for tasks like syncing products to third-party services, sending periodic reports, or cleaning up stale data.
Contents
- When to Use Scheduled Jobs
- Creating a Scheduled Job
- Configuration Options
- Executing Workflows in Scheduled Jobs
- Cron Expression Examples
- Best Practices
When to Use Scheduled Jobs
Use scheduled jobs when you need to perform actions periodically:
- ✅ Syncing data with third-party services on a schedule
- ✅ Sending periodic reports (daily, weekly)
- ✅ Cleaning up stale data (expired carts, old sessions)
- ✅ Generating batch exports
- ✅ Recalculating aggregated data
Don't use scheduled jobs for:
- ❌ Reacting to events (use subscribers instead)
- ❌ One-time tasks (use workflows directly)
- ❌ Real-time processing (use API routes + workflows)
Scheduled Jobs vs Subscribers:
- Scheduled Job: Finds carts updated >24h ago and sends emails (polling pattern)
- Subscriber: Reacts to
order.createdand sends an email (event-driven)
For most use cases, subscribers are preferred when you need to react to specific events.
Creating a Scheduled Job
Create a TypeScript file in the src/jobs/ directory:
// src/jobs/sync-products.ts
import { MedusaContainer } from "@medusajs/framework/types"
export default async function syncProductsJob(container: MedusaContainer) {
const logger = container.resolve("logger")
logger.info("Starting product sync...")
// Resolve services from container
const productService = container.resolve("product")
const myService = container.resolve("my-custom-service")
try {
// Your job logic here
const products = await productService.listProducts({ active: true })
for (const product of products) {
// Process each product
await myService.syncToExternalSystem(product)
}
logger.info("Product sync completed successfully")
} catch (error) {
logger.error(`Product sync failed: ${error.message}`)
// Don't throw - let the job complete and retry on next schedule
}
}
export const config = {
name: "sync-products-daily", // Unique name for the job
schedule: "0 0 * * *", // Cron expression: midnight daily
}Configuration Options
export const config = {
name: "my-job", // Required: unique identifier
schedule: "* * * * *", // Required: cron expression
numberOfExecutions: 3, // Optional: limit total scheduled executions
}Configuration Properties
- name (required): Unique identifier for the job across your application
- schedule (required): Cron expression defining when to run
- numberOfExecutions (optional): Maximum number of times to execute the job according to its schedule
⚠️ CRITICAL - Understanding numberOfExecutions:
numberOfExecutions limits how many times the job runs on its schedule, NOT immediately on server start.
// ❌ WRONG UNDERSTANDING: This will NOT run immediately on server start
export const config = {
name: "test-job",
schedule: "0 0 * * *", // Daily at midnight
numberOfExecutions: 1, // Will run ONCE at the next midnight, not now!
}
// ✅ CORRECT: To test a job immediately, use a frequent schedule
export const config = {
name: "test-job",
schedule: "* * * * *", // Every minute
numberOfExecutions: 1, // Will run once at the next minute
}
// ✅ CORRECT: Testing with multiple runs
export const config = {
name: "test-job",
schedule: "*/5 * * * *", // Every 5 minutes
numberOfExecutions: 3, // Will run 3 times (at 0, 5, 10 minutes), then stop
}Key points:
- The job waits for the first scheduled time before executing
numberOfExecutions: 1with a daily schedule means it runs once the next day- To test immediately, use a frequent schedule like
"* * * * *"(every minute) - After reaching
numberOfExecutions, the job stops running permanently
Executing Workflows in Scheduled Jobs
⚠️ BEST PRACTICE: Use workflows for mutations in scheduled jobs. This ensures proper error handling and rollback capabilities.
// src/jobs/send-weekly-newsletter.ts
import { MedusaContainer } from "@medusajs/framework/types"
import { sendNewsletterWorkflow } from "../workflows/send-newsletter"
export default async function sendNewsletterJob(container: MedusaContainer) {
const logger = container.resolve("logger")
const query = container.resolve("query")
logger.info("Sending weekly newsletter...")
try {
// Query for data
const { data: customers } = await query.graph({
entity: "customer",
fields: ["id", "email"],
filters: {
newsletter_subscribed: true,
},
})
logger.info(`Found ${customers.length} subscribers`)
// Execute workflow
await sendNewsletterWorkflow(container).run({
input: {
customer_ids: customers.map((c) => c.id),
},
})
logger.info("Newsletter sent successfully")
} catch (error) {
logger.error(`Newsletter job failed: ${error.message}`)
}
}
export const config = {
name: "send-weekly-newsletter",
schedule: "0 0 * * 0", // Every Sunday at midnight
}Cron Expression Examples
Cron format: minute hour day-of-month month day-of-week
// Every minute
schedule: "* * * * *"
// Every 5 minutes
schedule: "*/5 * * * *"
// Every hour at minute 0
schedule: "0 * * * *"
// Every day at midnight (00:00)
schedule: "0 0 * * *"
// Every day at 2:30 AM
schedule: "30 2 * * *"
// Every Sunday at midnight
schedule: "0 0 * * 0"
// Every Monday at 9 AM
schedule: "0 9 * * 1"
// First day of every month at midnight
schedule: "0 0 1 * *"
// Every weekday (Mon-Fri) at 6 PM
schedule: "0 18 * * 1-5"
// Every 6 hours
schedule: "0 */6 * * *"Tip: Use crontab.guru to build and validate cron expressions.
Best Practices
1. Always Use Logging
export default async function myJob(container: MedusaContainer) {
const logger = container.resolve("logger")
logger.info("Job started")
try {
// Job logic
logger.info("Job completed successfully")
} catch (error) {
logger.error(`Job failed: ${error.message}`, { error })
}
}2. Handle Errors Gracefully
Don't throw errors at the top level - log them and let the job complete:
// ❌ BAD: Throws and stops execution
export default async function myJob(container: MedusaContainer) {
const service = container.resolve("my-service")
const items = await service.getItems() // Might throw
// Job stops if this throws
}
// ✅ GOOD: Catches errors and logs
export default async function myJob(container: MedusaContainer) {
const logger = container.resolve("logger")
try {
const service = container.resolve("my-service")
const items = await service.getItems()
// Process items
} catch (error) {
logger.error(`Job failed: ${error.message}`)
// Job completes, will retry on next schedule
}
}3. Make Jobs Idempotent
Design jobs to be safely re-runnable:
// ✅ GOOD: Idempotent job
export default async function syncProducts(container: MedusaContainer) {
const logger = container.resolve("logger")
const myService = container.resolve("my-service")
// Check what's already synced
const lastSyncTime = await myService.getLastSyncTime()
// Only sync products updated since last sync
const { data: products } = await query.graph({
entity: "product",
filters: {
updated_at: { $gte: lastSyncTime },
},
})
// Sync products (upsert, don't insert)
for (const product of products) {
await myService.upsertToExternalSystem(product)
}
// Update last sync time
await myService.setLastSyncTime(new Date())
}4. Use Workflows for Mutations
// ✅ GOOD: Uses workflow for mutations
import { deleteCartsWorkflow } from "../workflows/delete-carts"
export default async function cleanupExpiredCarts(container: MedusaContainer) {
const logger = container.resolve("logger")
const query = container.resolve("query")
// Find expired carts
const { data: carts } = await query.graph({
entity: "cart",
fields: ["id"],
filters: {
updated_at: {
$lte: new Date(Date.now() - 24 * 60 * 60 * 1000), // 24 hours ago
},
},
})
logger.info(`Found ${carts.length} expired carts`)
// Use workflow for deletion (import at top of file)
await deleteCartsWorkflow(container).run({
input: {
cart_ids: carts.map((c) => c.id),
},
})
logger.info("Expired carts cleaned up")
}5. Add Metrics/Monitoring
export default async function myJob(container: MedusaContainer) {
const logger = container.resolve("logger")
const startTime = Date.now()
try {
// Job logic
const processed = 100 // Track what you processed
const duration = Date.now() - startTime
logger.info(`Job completed: ${processed} items in ${duration}ms`)
} catch (error) {
logger.error(`Job failed after ${Date.now() - startTime}ms`)
}
}6. Test with Limited Executions
When testing, use a frequent schedule with limited executions:
// ✅ CORRECT: Frequent schedule for immediate testing
export const config = {
name: "test-job",
schedule: "* * * * *", // Every minute
numberOfExecutions: 3, // Run 3 times (next 3 minutes), then stop
}
// ❌ WRONG: This won't help with testing
export const config = {
name: "test-job",
schedule: "0 0 * * *", // Daily at midnight
numberOfExecutions: 1, // Will only run ONCE at next midnight, not useful for testing
}Remember: numberOfExecutions doesn't make the job run immediately - it limits how many times it runs on its schedule.
Complete Example: Abandoned Cart Email Job
// src/jobs/send-abandoned-cart-emails.ts
import { MedusaContainer } from "@medusajs/framework/types"
import { sendAbandonedCartEmailWorkflow } from "../workflows/send-abandoned-cart-email"
export default async function abandonedCartEmailJob(
container: MedusaContainer
) {
const logger = container.resolve("logger")
const query = container.resolve("query")
logger.info("Starting abandoned cart email job...")
try {
// Find carts updated more than 24 hours ago that haven't completed
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
const { data: carts } = await query.graph({
entity: "cart",
fields: ["id", "email", "customer_id"],
filters: {
updated_at: {
$lte: twentyFourHoursAgo,
},
completed_at: null,
email: { $ne: null }, // Must have email
},
})
logger.info(`Found ${carts.length} abandoned carts`)
// Process in batches
for (const cart of carts) {
try {
await sendAbandonedCartEmailWorkflow(container).run({
input: {
cart_id: cart.id,
email: cart.email,
},
})
logger.info(`Sent email for cart ${cart.id}`)
} catch (error) {
logger.error(`Failed to send email for cart ${cart.id}: ${error.message}`)
// Continue with other carts
}
}
logger.info("Abandoned cart email job completed")
} catch (error) {
logger.error(`Abandoned cart job failed: ${error.message}`)
}
}
export const config = {
name: "send-abandoned-cart-emails",
schedule: "0 */6 * * *", // Every 6 hours
}Troubleshooting Common Medusa Backend Issues
This guide covers common errors and their solutions when building with Medusa.
Contents
Module Registration Errors
Error: Module "X" not registered
Error: Module "my-module" is not registered in the containerCause: Module not added to medusa-config.ts or server not restarted.
Solution: 1. Add module to medusa-config.ts:
module.exports = defineConfig({
modules: [
{ resolve: "./src/modules/my-module" }
],
})2. Restart the Medusa server
Error: Cannot find module './modules/X'
Error: Cannot find module './modules/my-module'Cause: Module path is incorrect or module structure is incomplete.
Solution: 1. Verify module structure:
src/modules/my-module/
├── models/
│ └── my-model.ts
├── service.ts
└── index.ts2. Ensure index.ts exports the module correctly 3. Check path in medusa-config.ts matches actual directory
API Route Errors
Error: validatedBody is undefined
TypeError: Cannot read property 'email' of undefinedCause: Forgot to add validation middleware or accessing req.validatedBody instead of req.body.
Solution: 1. Add validation middleware:
// middlewares.ts
export const myMiddlewares: MiddlewareRoute[] = [
{
matcher: "/store/my-route",
method: "POST",
middlewares: [validateAndTransformBody(MySchema)],
},
]2. Access req.validatedBody not req.body
Error: queryConfig is undefined
TypeError: Cannot spread undefinedCause: Using ...req.queryConfig without setting up query config middleware.
Solution: Add validateAndTransformQuery middleware:
import { createFindParams } from "@medusajs/medusa/api/utils/validators"
export const GetMyItemsSchema = createFindParams()
export default defineMiddlewares({
routes: [
{
matcher: "/store/my-items",
method: "GET",
middlewares: [
validateAndTransformQuery(GetMyItemsSchema, {
defaults: ["id", "name"],
isList: true,
}),
],
},
],
})Error: MedusaError not being formatted
Error: [object Object]Cause: Throwing regular Error instead of MedusaError.
Solution:
// ❌ WRONG
throw new Error("Not found")
// ✅ CORRECT
import { MedusaError } from "@medusajs/framework/utils"
throw new MedusaError(MedusaError.Types.NOT_FOUND, "Not found")Error: Middleware not applying
Error: Route is not being validatedCause: Middleware matcher doesn't match route path or middleware not registered.
Solution: 1. Check matcher pattern matches your route:
// For route: /store/my-route
matcher: "/store/my-route" // Exact match
// For multiple routes: /store/my-route, /store/my-route/123
matcher: "/store/my-route*" // Wildcard2. Ensure middleware is exported and registered in api/middlewares.ts
Authentication Errors
Error: auth_context is undefined
TypeError: Cannot read property 'actor_id' of undefinedCause: Route is not protected or user is not authenticated.
Solution: 1. Check if route is under protected prefix (/admin/* or /store/customers/me/*) 2. If custom prefix, add authentication middleware:
export default defineMiddlewares({
routes: [
{
matcher: "/custom/admin*",
middlewares: [authenticate("user", ["session", "bearer", "api-key"])],
},
],
})3. For optional auth, check if auth_context exists:
const userId = req.auth_context?.actor_id
if (!userId) {
// Handle unauthenticated case
}General Debugging Tips
Enable Debug Logging
# Set log level to debug
LOG_LEVEL=debug npx medusa developLog Values In Workflows with Transform
import {
createStep,
createWorkflow,
StepResponse,
WorkflowResponse,
transform,
} from "@medusajs/framework/workflows-sdk"
const step1 = createStep(
"step-1",
async () => {
const message = "Hello from step 1!"
return new StepResponse(
message
)
}
)
export const myWorkflow = createWorkflow(
"my-workflow",
() => {
const response = step1()
const transformedMessage = transform(
{ response },
(data) => {
const upperCase = data.response.toUpperCase()
console.log("Transformed Data:", upperCase)
return upperCase
}
)
return new WorkflowResponse({
response: transformedMessage,
})
}
)Related skills
Forks & variants (1)
Building With Medusa has 1 known copy in the catalog totaling 170 installs. They canonicalize to this original listing.
- medusajs - 170 installs
How it compares
Pick this over generic Express or NestJS API skills when the codebase is Medusa.js and routes must follow Medusa middleware, auth, and workflow conventions.
FAQ
What architecture flow must Medusa backend code follow?
Module provides data and CRUD, Workflow handles business logic and mutations, API Route exposes HTTP, Frontend calls via SDK.
How should prices be stored in Medusa?
Prices store as-is, for example 49.99 as 49.99, never multiplied by 100 into cents on save.
Which HTTP methods are allowed on Medusa API routes?
Only GET, POST, and DELETE are allowed; never use PUT or PATCH.
Is Building With Medusa safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.