All skills
medusajs avatar

/building-with-medusa

@34c71b3 official
by Medusamedusajs/medusa-agent-skills225 stars
29

Load automatically when planning, researching, or implementing ANY Medusa backend features (custom modules, API routes, workflows, data models, module links, business logic). REQUIRED for all Medusa backend work in ALL modes (planning, implementation, exploration). Contains architectural patterns, best practices, and critical rules that MCP servers don't provide.

Use this Skill: https://skilld.dev/gh/medusajs/medusa-agent-skills/building-with-medusa

This session only. Nothing lands on disk.

referencequerying-data.md

≈6.4k tokens on demand. Your agent reads this file only when SKILL.md points to it.

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

⚠️ 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 listAndCount for 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

import { ContainerRegistrationKeys } from "@medusajs/framework/utils"

const query = req.scope.resolve(ContainerRegistrationKeys.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)

import { ContainerRegistrationKeys } from "@medusajs/framework/utils"

// In API routes
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)

const { data: products } = await query.graph({
  entity: "product",
  fields: ["id", "title"],
})

// In subscribers/scheduled jobs
const query = container.resolve(ContainerRegistrationKeys.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 filterable properties 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/index
  2. Add to medusa-config.ts:

    module.exports = defineConfig({
      modules: [
        {
          resolve: "@medusajs/index",
        },
      ],
    })
  3. Enable the feature flag in .env:

    MEDUSA_FF_INDEX_ENGINE=true
  4. Run migrations:

    npx medusa db:migrate
  5. 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()
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"

const query = req.scope.resolve(ContainerRegistrationKeys.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 properties
Important 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:

import { ContainerRegistrationKeys } from "@medusajs/framework/utils"

// Outside workflows
const query = req.scope.resolve(ContainerRegistrationKeys.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

import { ContainerRegistrationKeys } from "@medusajs/framework/utils"

export async function GET(req: MedusaRequest, res: MedusaResponse) {
  const query = req.scope.resolve(ContainerRegistrationKeys.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

import { ContainerRegistrationKeys } from "@medusajs/framework/utils"

export async function GET(req: MedusaRequest, res: MedusaResponse) {
  const query = req.scope.resolve(ContainerRegistrationKeys.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

import { ContainerRegistrationKeys } from "@medusajs/framework/utils"

export async function GET(req: MedusaRequest, res: MedusaResponse) {
  const query = req.scope.resolve(ContainerRegistrationKeys.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

import { ContainerRegistrationKeys } from "@medusajs/framework/utils"

export async function GET(req: MedusaRequest, res: MedusaResponse) {
  const query = req.scope.resolve(ContainerRegistrationKeys.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

import { ContainerRegistrationKeys } from "@medusajs/framework/utils"

export async function GET(req: MedusaRequest, res: MedusaResponse) {
  const query = req.scope.resolve(ContainerRegistrationKeys.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 })
}

Source: SKILL.md on GitHub

1 warning16d5 checks · Risk SAFE
  • Gen Agent Trust Hub16d

    This skill is a comprehensive development guide for building MedusaJS backend applications. It provides detailed architectural patterns, best practices, and implementation checklists for modules, workflows, API routes, and data models. No malicious patterns or security risks were identified.

  • Socket16d

    No alerts

  • Snyk16d

    Risk: LOW · No issues

  • Runlayer7mo

    9/14 files flagged

  • ZeroLeaks5mo

    2 findings · Score: 80/100

Signed by skilld at 34c71b3. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 days ago.

Activeupdated 2 months ago

README badge

README badge for medusajs/medusa-agent-skills/building-with-medusa