From 4cda412243e1256f0cba61ad248c0237cffd1f43 Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Tue, 26 Aug 2025 11:49:57 +0300 Subject: [PATCH] docs: product builder tutorial (#13210) * initial * docs: product builder tutorial --- www/apps/book/public/llms-full.txt | 5729 ++++++++++++++- .../tutorials/preorder/page.mdx | 2 +- .../tutorials/product-builder/page.mdx | 6413 +++++++++++++++++ www/apps/resources/generated/edit-dates.mjs | 3 +- www/apps/resources/generated/files-map.mjs | 4 + .../generated-commerce-modules-sidebar.mjs | 8 + .../generated-how-to-tutorials-sidebar.mjs | 9 + .../generated/generated-tools-sidebar.mjs | 8 + .../resources/sidebars/how-to-tutorials.mjs | 7 + www/packages/tags/src/tags/auth.ts | 8 +- www/packages/tags/src/tags/nextjs.ts | 4 + www/packages/tags/src/tags/product.ts | 4 + www/packages/tags/src/tags/server.ts | 4 + www/packages/tags/src/tags/tutorial.ts | 4 + 14 files changed, 12200 insertions(+), 7 deletions(-) create mode 100644 www/apps/resources/app/how-to-tutorials/tutorials/product-builder/page.mdx diff --git a/www/apps/book/public/llms-full.txt b/www/apps/book/public/llms-full.txt index b0d9ad15ac..78adf7de25 100644 --- a/www/apps/book/public/llms-full.txt +++ b/www/apps/book/public/llms-full.txt @@ -61418,7 +61418,7 @@ Next, you'll create the API route that exposes the workflow's functionality to c An API route is created in a `route.ts` file under a sub-directory of the `src/api` directory. The path of the API route is the file's path relative to `src/api`. -Refer to the [API routes](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md) to learn more about them. +Refer to the [API routes](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md) documentation to learn more about them. Create the file `src/api/admin/variants/[id]/preorders/route.ts` with the following content: @@ -64065,6 +64065,5733 @@ If you encounter issues not covered in the troubleshooting guides: 2. Join the [Medusa Discord community](https://discord.gg/medusajs) for real-time support from community members. +# Implement Product Builder in Medusa + +In this tutorial, you'll learn how to implement a product builder in Medusa. + +When you install a Medusa application, you get a fully-fledged commerce platform with a Framework for customization. The Medusa application's commerce features are built around [Commerce Modules](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/index.html.md) that are available out-of-the-box. + +A product builder allows customers to customize the product before adding it to the cart. This may include entering custom options like engraving text, adding complementary products to the cart, or purchasing add-ons with the product, such as insurance. + +## Summary + +By following this tutorial, you will learn how to: + +- Install and set up Medusa with the Next.js Starter Storefront. +- Define and manage data models useful for the product builder. +- Allow admin users to manage the builder configurations of a product from Medusa Admin. +- Customize the storefront to allow customers to choose a product's builder configurations. +- Customize cart and order pages on the storefront to reflect the selected builder configurations of items. +- Customize the order details page on the Medusa Admin to reflect the selected builder configurations of items. + +You can follow this tutorial whether you're new to Medusa or an advanced Medusa developer. + +![Screenshot of how the product builder will look like in the storefront](https://res.cloudinary.com/dza7lstvk/image/upload/v1755101580/Medusa%20Resources/CleanShot_2025-08-13_at_19.12.48_2x_tf4goa.png) + +- [Full Code](https://github.com/medusajs/examples/tree/main/product-builder): Find the full code for this tutorial in this repository. +- [OpenApi Specs for Postman](https://res.cloudinary.com/dza7lstvk/raw/upload/v1755010179/OpenApi/Product-Builder_wvhqtq.yaml): Import this OpenApi Specs file into tools like Postman. + +*** + +## Step 1: Install a Medusa Application + +### Prerequisites + +- [Node.js v20+](https://nodejs.org/en/download) +- [Git CLI tool](https://git-scm.com/downloads) +- [PostgreSQL](https://www.postgresql.org/download/) + +Start by installing the Medusa application on your machine with the following command: + +```bash +npx create-medusa-app@latest +``` + +You'll first be asked for the project's name. Then, when asked whether you want to install the [Next.js Starter Storefront](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/nextjs-starter/index.html.md), choose Yes. + +Afterward, the installation process will start, which will install the Medusa application in a directory with your project's name, and the Next.js Starter Storefront in a separate directory with the `{project-name}-storefront` name. + +The Medusa application is composed of a headless Node.js server and an admin dashboard. The storefront is installed or custom-built separately and connects to the Medusa application through its REST endpoints, called [API routes](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md). Learn more in [Medusa's Architecture documentation](https://docs.medusajs.com/docs/learn/introduction/architecture/index.html.md). + +Once the installation finishes successfully, the Medusa Admin dashboard will open with a form to create a new user. Enter the user's credentials and submit the form. Afterward, you can log in with the new user and explore the dashboard. + +Check out the [troubleshooting guides](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/troubleshooting/create-medusa-app-errors/index.html.md) for help. + +*** + +## Step 2: Create Product Builder Module + +In Medusa, you can build custom features in a [module](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md). A module is a reusable package with the data models and functionalities related to a single feature or domain. Medusa integrates the module into your application without implications or side effects on your setup. + +Refer to the [Modules](https://docs.medusajs.com/docs/learn/fundamentals/modules/index.html.md) documentation to learn more. + +In this step, you'll build a Product Builder Module that defines the data models and logic to manage product builder configurations. The module will support three types of configurations: + +1. **Custom Fields**: Allow customers to enter personalized information like engraving text or custom messages for the product. +2. **Complementary Products**: Suggest related products that enhance the main product, like keyboards with computers. +3. **Add-ons**: Optional extras like warranties, insurance, or premium features that customers can purchase alongside the main product. + +### a. Create Module Directory + +Create the directory `src/modules/product-builder` that will hold the Product Builder Module's code. + +### b. Create Data Models + +A data model represents a table in the database. You create data models using Medusa's Data Model Language (DML). It simplifies defining a table's columns, relations, and indexes with straightforward methods and configurations. + +Refer to the [Data Models](https://docs.medusajs.com/docs/learn/fundamentals/modules#1-create-data-model/index.html.md) documentation to learn more. + +For the Product Builder Module, you'll define four data models to represent the different aspects of product customization. + +#### ProductBuilder Data Model + +The first data model will hold the main builder configurations for a product. It will have relations to the custom fields, complementary products, and add-ons. + +To create the data model, create the file `src/modules/product-builder/models/product-builder.ts` with the following content: + +```ts title="src/modules/product-builder/models/product-builder.ts" highlights={productBuilderHighlights} +import { model } from "@medusajs/framework/utils" +import ProductBuilderCustomField from "./product-builder-custom-field" +import ProductBuilderComplementary from "./product-builder-complementary" +import ProductBuilderAddon from "./product-builder-addon" + +const ProductBuilder = model.define("product_builder", { + id: model.id().primaryKey(), + product_id: model.text().unique(), + custom_fields: model.hasMany(() => ProductBuilderCustomField, { + mappedBy: "product_builder", + }), + complementary_products: model.hasMany(() => ProductBuilderComplementary, { + mappedBy: "product_builder", + }), + addons: model.hasMany(() => ProductBuilderAddon, { + mappedBy: "product_builder", + }), +}) + +export default ProductBuilder +``` + +The `ProductBuilder` data model has the following properties: + +- `id`: The primary key of the table. +- `product_id`: The ID of the product that this builder configuration applies to. + - Later, you'll learn how to link this data model to Medusa's `Product` data model. +- `custom_fields`: Fields that the customer can personalize. +- `complementary_products`: Products to suggest alongside the main product. +- `addons`: Products that the customer can buy alongside the main product. + +Ignore the type errors for the related data models. You'll create them next. + +Learn more about defining data model properties in the [Property Types documentation](https://docs.medusajs.com/docs/learn/fundamentals/data-models/properties/index.html.md). + +#### ProductBuilderCustomField Data Model + +The `ProductBuilderCustomField` data model represents fields that the customer can personalize. For example, engraving text or custom messages. + +To create the data model, create the file `src/modules/product-builder/models/product-builder-custom-field.ts` with the following content: + +```ts title="src/modules/product-builder/models/product-builder-custom-field.ts" highlights={customFieldHighlights} +import { model } from "@medusajs/framework/utils" +import ProductBuilder from "./product-builder" + +const ProductBuilderCustomField = model.define("product_builder_custom_field", { + id: model.id().primaryKey(), + name: model.text(), + type: model.text(), + description: model.text().nullable(), + is_required: model.boolean().default(false), + product_builder: model.belongsTo(() => ProductBuilder, { + mappedBy: "custom_fields", + }), +}) + +export default ProductBuilderCustomField +``` + +The `ProductBuilderCustomField` data model has the following properties: + +- `id`: The primary key of the table. +- `name`: The display name shown to customers (for example, "Engraving Text" or "Custom Message"). +- `type`: The input type, such as `text` or `number`. +- `description`: Optional helper text to guide customers (for example, "Enter your name to be engraved"). +- `is_required`: Whether customers must fill this field before adding the product to cart. +- `product_builder`: A relation back to the parent `ProductBuilder` configuration. + +#### ProductBuilderComplementary Data Model + +The `ProductBuilderComplementary` data model represents products that are suggested alongside the main product. For example, if you're selling an iPad, you can suggest a keyboard to be purchased together. + +To create the data model, create the file `src/modules/product-builder/models/product-builder-complementary.ts` with the following content: + +```ts title="src/modules/product-builder/models/product-builder-complementary.ts" highlights={complementaryHighlights} +import { model } from "@medusajs/framework/utils" +import ProductBuilder from "./product-builder" + +const ProductBuilderComplementary = model.define("product_builder_complementary", { + id: model.id().primaryKey(), + product_id: model.text(), + product_builder: model.belongsTo(() => ProductBuilder, { + mappedBy: "complementary_products", + }), +}) + +export default ProductBuilderComplementary +``` + +The `ProductBuilderComplementary` data model has the following properties: + +- `id`: The primary key of the table. +- `product_id`: The ID of the complementary product to suggest. + - Later, you'll learn how to link this to Medusa's `Product` data model. +- `product_builder`: A relation back to the parent `ProductBuilder` configuration. + +#### ProductBuilderAddon Data Model + +The last data model you'll implement is the `ProductBuilderAddon` data model, which represents optional add-on products like warranties or premium features. Add-ons are typically only sold with the main product. + +To create the data model, create the file `src/modules/product-builder/models/product-builder-addon.ts` with the following content: + +```ts title="src/modules/product-builder/models/product-builder-addon.ts" highlights={addonHighlights} +import { model } from "@medusajs/framework/utils" +import ProductBuilder from "./product-builder" + +const ProductBuilderAddon = model.define("product_builder_addon", { + id: model.id().primaryKey(), + product_id: model.text(), + product_builder: model.belongsTo(() => ProductBuilder, { + mappedBy: "addons", + }), +}) + +export default ProductBuilderAddon +``` + +The `ProductBuilderAddon` data model has the following properties: + +- `id`: The primary key of the table. +- `product_id`: The ID of the add-on product (for example, warranty or insurance product). + - Later, you'll learn how to link this to Medusa's `Product` data model. +- `product_builder`: A relation back to the parent `ProductBuilder` configuration. + +### c. Create Module's Service + +You can manage your module's data models in a service. + +A service is a TypeScript class that the module exports. In the service's methods, you can connect to the database, allowing you to manage your data models, or connect to a third-party service, which is useful if you're integrating with external services. + +Refer to the [Module Service documentation](https://docs.medusajs.com/docs/learn/fundamentals/modules#2-create-service/index.html.md) to learn more. + +To create the Product Builder Module's service, create the file `src/modules/product-builder/service.ts` with the following content: + +```ts title="src/modules/product-builder/service.ts" +import { MedusaService } from "@medusajs/framework/utils" +import ProductBuilder from "./models/product-builder" +import ProductBuilderCustomField from "./models/product-builder-custom-field" +import ProductBuilderComplementary from "./models/product-builder-complementary" +import ProductBuilderAddon from "./models/product-builder-addon" + +class ProductBuilderModuleService extends MedusaService({ + ProductBuilder, + ProductBuilderCustomField, + ProductBuilderComplementary, + ProductBuilderAddon, +}) {} + +export default ProductBuilderModuleService +``` + +The `ProductBuilderModuleService` extends `MedusaService`, which generates a class with data-management methods for your module's data models. This saves you time on implementing Create, Read, Update, and Delete (CRUD) methods. + +So, the `ProductBuilderModuleService` class now has methods like `createProductBuilders` and `retrieveProductBuilder`. + +Find all methods generated by the `MedusaService` in [the Service Factory](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/service-factory-reference/index.html.md) reference. + +### d. Create the Module Definition + +The final piece to a module is its definition, which you export in an `index.ts` file at its root directory. This definition tells Medusa the name of the module and its service. + +So, create the file `src/modules/product-builder/index.ts` with the following content: + +```ts title="src/modules/product-builder/index.ts" highlights={moduleHighlights} +import Service from "./service" +import { Module } from "@medusajs/framework/utils" + +export const PRODUCT_BUILDER_MODULE = "productBuilder" + +export default Module(PRODUCT_BUILDER_MODULE, { + service: Service, +}) +``` + +You use the `Module` function to create the module's definition. It accepts two parameters: + +1. The module's name, which is `productBuilder`. +2. An object with a required property `service` indicating the module's service. + +You also export the module's name as `PRODUCT_BUILDER_MODULE` so you can reference it later. + +### Add Module to Medusa's Configurations + +Once you finish building the module, add it to Medusa's configurations to start using it. + +In `medusa-config.ts`, add a `modules` property and pass an array with your custom module: + +```ts title="medusa-config.ts" +module.exports = defineConfig({ + // ... + modules: [ + { + resolve: "./src/modules/product-builder", + }, + ], +}) +``` + +Each object in the `modules` array has a `resolve` property, whose value is either a path to the module's directory, or an `npm` package’s name. + +### Generate Migrations + +Since data models represent tables in the database, you define how they're created in the database with migrations. A migration is a TypeScript class that defines database changes made by a module. + +Refer to the [Migrations documentation](https://docs.medusajs.com/docs/learn/fundamentals/modules#5-generate-migrations/index.html.md) to learn more. + +Medusa's CLI tool can generate the migrations for you. To generate a migration for the Product Builder Module, run the following command in your Medusa application's directory: + +```bash +npx medusa db:generate productBuilder +``` + +The `db:generate` command of the Medusa CLI accepts the name of the module to generate the migration for. You'll now have a `migrations` directory under `src/modules/product-builder` that holds the generated migration. + +Then, to reflect these migrations on the database, run the following command: + +```bash +npx medusa db:migrate +``` + +The tables for the data models are now created in the database. + +*** + +## Step 3: Define Links between Data Models + +Since Medusa isolates modules to integrate them into your application without side effects, you can't directly create relationships between data models of different modules. + +Instead, Medusa provides a mechanism to define links between data models, and retrieve and manage linked records while maintaining module isolation. + +Refer to the [Module Links](https://docs.medusajs.com/docs/learn/fundamentals/module-links/index.html.md) documentation to learn more about defining links. + +In this step, you'll define links between the data models in the Product Builder Module and the data models in Medusa's Product Module: + +- `ProductBuilder` ↔ `Product`: A product builder record represents the builder configurations of a product. +- `ProductBuilderComplementary` ↔ `Product`: A complementary product record suggests a Medusa product related to the main product. +- `ProductBuilderAddon` ↔ `Product`: An add-on product record suggests a Medusa product that can be added to the main product in the cart. + +### a. ProductBuilder ↔ Product + +To define a link between the `ProductBuilder` and `Product` data models, create the file `src/links/product-builder-product.ts` with the following content: + +```ts title="src/links/product-builder-product.ts" highlights={productBuilderLinkHighlights} +import ProductBuilderModule from "../modules/product-builder" +import ProductModule from "@medusajs/medusa/product" +import { defineLink } from "@medusajs/framework/utils" + +export default defineLink( + { + linkable: ProductBuilderModule.linkable.productBuilder, + deleteCascade: true, + }, + ProductModule.linkable.product +) +``` + +You define a link using the `defineLink` function. It accepts two parameters: + +1. An object indicating the first data model part of the link. A module has a special `linkable` property that contains link configurations for its data models. You pass the linkable configurations of the Product Builder Module's `ProductBuilder` data model, and you enable the `deleteCascade` option to automatically delete the builder configuration when the product is deleted. +2. An object indicating the second data model part of the link. You pass the linkable configurations of the Product Module's `Product` data model. + +### b. ProductBuilderComplementary ↔ Product + +Next, to define a link between the `ProductBuilderComplementary` and `Product` data models, create the file `src/links/product-builder-complementary-product.ts` with the following content: + +```ts title="src/links/product-builder-complementary-product.ts" highlights={complementaryLinkHighlights} +import ProductBuilderModule from "../modules/product-builder" +import ProductModule from "@medusajs/medusa/product" +import { defineLink } from "@medusajs/framework/utils" + +export default defineLink( + { + linkable: ProductBuilderModule.linkable.productBuilderComplementary, + deleteCascade: true, + }, + ProductModule.linkable.product +) +``` + +You define a link similarly to the previous one. You also enable the `deleteCascade` option to automatically delete the complementary product record when the main product is deleted. + +### c. ProductBuilderAddon ↔ Product + +Finally, to define a link between the `ProductBuilderAddon` and `Product` data models, create the file `src/links/product-builder-addon-product.ts` with the following content: + +```ts title="src/links/product-builder-addon-product.ts" highlights={addonLinkHighlights} +import ProductBuilderModule from "../modules/product-builder" +import ProductModule from "@medusajs/medusa/product" +import { defineLink } from "@medusajs/framework/utils" + +export default defineLink( + { + linkable: ProductBuilderModule.linkable.productBuilderAddon, + deleteCascade: true, + }, + ProductModule.linkable.product +) +``` + +Similarly to the previous links, you define a link between the `ProductBuilderAddon` and `Product` data models. You also enable the `deleteCascade` option to automatically delete the add-on product record when the main product is deleted. + +### d. Sync Links to Database + +After defining links, you need to sync them to the database. This creates the necessary tables to store the links. + +To sync the links to the database, run the migrations command again in the Medusa application's directory: + +```bash +npx medusa db:migrate +``` + +This command will create the necessary tables to store the links between your Product Builder Module and Medusa's Product Module. + +*** + +## Step 4: Manage Product Builder Configurations + +In this step, you'll implement the logic to manage product builder configurations. You'll also expose this functionality to clients, allowing you later to use it in the admin dashboard customizations. + +To implement the product builder management functionality, you'll create: + +- A [workflow](https://docs.medusajs.com/docs/learn/fundamentals/workflows/index.html.md) to create or update (upsert) product builder configurations. +- An [API route](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md) to expose the workflow's functionality to client applications. + +### a. Upsert Product Builder Workflow + +The first workflow you'll implement creates or updates builder configurations for a product. + +A workflow is a series of queries and actions, called steps, that complete a task. A workflow is similar to a function, but it allows you to track its executions' progress, define roll-back logic, and configure other advanced features. + +Refer to the [Workflows](https://docs.medusajs.com/docs/learn/fundamentals/workflows/index.html.md) documentation to learn more. + +The workflow you'll build will have the following steps: + +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the product builder configuration, if it exists. +- [prepareProductBuilderCustomFieldsStep](#prepareProductBuilderCustomFieldsStep): Prepare custom fields to create, update, or delete. +- [createProductBuilderCustomFieldsStep](#createProductBuilderCustomFieldsStep): Create custom fields for the product builder. +- [updateProductBuilderCustomFieldsStep](#updateProductBuilderCustomFieldsStep): Update custom fields for the product builder. +- [deleteProductBuilderCustomFieldsStep](#deleteProductBuilderCustomFieldsStep): Delete custom fields for the product builder. +- [prepareProductBuilderComplementaryProductsStep](#prepareProductBuilderComplementaryProductsStep): Prepare complementary products to create, or delete. +- [createProductBuilderComplementaryProductsStep](#createProductBuilderComplementaryProductsStep): Create complementary products for the product builder. +- [deleteProductBuilderComplementaryProductsStep](#deleteProductBuilderComplementaryProductsStep): Delete complementary products for the product builder. +- [prepareProductBuilderAddonsStep](#prepareProductBuilderAddonsStep): Prepare addon products to create, or delete. +- [createProductBuilderAddonsStep](#createProductBuilderAddonsStep): Create addon products for the product builder. +- [deleteProductBuilderAddonsStep](#deleteProductBuilderAddonsStep): Delete addon products for the product builder. +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the updated product builder configuration. + +The `useQueryGraphStep`, `createRemoteLinkStep`, and `dismissRemoteLinkStep` are available through Medusa's `@medusajs/medusa/core-flows` package. You'll implement other steps in the workflow. + +#### createProductBuilderStep + +The `createProductBuilderStep` creates a new product builder configuration. + +To create the step, create the file `src/workflows/steps/create-product-builder.ts` with the following content: + +```ts title="src/workflows/steps/create-product-builder.ts" highlights={createProductBuilderStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { PRODUCT_BUILDER_MODULE } from "../../modules/product-builder" + +export type CreateProductBuilderStepInput = { + product_id: string +} + +export const createProductBuilderStep = createStep( + "create-product-builder", + async (input: CreateProductBuilderStepInput, { container }) => { + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + + const productBuilder = await productBuilderModuleService.createProductBuilders({ + product_id: input.product_id, + }) + + return new StepResponse(productBuilder, productBuilder) + }, + async (productBuilder, { container }) => { + if (!productBuilder) {return} + + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + + await productBuilderModuleService.deleteProductBuilders(productBuilder.id) + } +) +``` + +You create a step with the `createStep` function. It accepts three parameters: + +1. The step's unique name. +2. An async function that receives two parameters: + - The step's input, which is an object with the product builder's properties. + - An object that has properties including the [Medusa container](https://docs.medusajs.com/docs/learn/fundamentals/medusa-container/index.html.md), which is a registry of Framework and commerce tools that you can access in the step. +3. An async compensation function that undoes the actions performed by the step function. This function is only executed if an error occurs during the workflow's execution. + +In the step function, you resolve the Product Builder Module's service from the Medusa container and create a product builder record. + +A step function must return a `StepResponse` instance with the step's output, which is the created product builder record in this case. + +You also pass the product builder record to the compensation function, which deletes the product builder record if an error occurs during the workflow's execution. + +#### prepareProductBuilderCustomFieldsStep + +The `prepareProductBuilderCustomFieldsStep` receives the custom fields from the workflow's input and returns which custom fields should be created, updated, or deleted. + +To create the step, create the file `src/workflows/upsert-product-builder.ts` with the following content: + +```ts title="src/workflows/upsert-product-builder.ts" highlights={prepareCustomFieldsStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { PRODUCT_BUILDER_MODULE } from "../../modules/product-builder" + +export type PrepareProductBuilderCustomFieldsStepInput = { + product_builder_id: string + custom_fields?: Array<{ + id?: string + name: string + type: string + is_required?: boolean + description?: string | null + }> +} + +export const prepareProductBuilderCustomFieldsStep = createStep( + "prepare-product-builder-custom-fields", + async (input: PrepareProductBuilderCustomFieldsStepInput, { container }) => { + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + + // Get existing custom fields for this product builder + const existingCustomFields = await productBuilderModuleService.listProductBuilderCustomFields({ + product_builder_id: input.product_builder_id, + }) + + // Separate operations: create, update, and delete + const toCreate: any[] = [] + const toUpdate: any[] = [] + + // TODO determine the fields to create, update, or delete + } +) +``` + +The step function receives as an input the product builder ID and the custom fields to manage. + +In the step, you resolve the Product Builder Module's service and retrieve the existing custom fields associated with the product builder. + +Then, you prepare arrays to hold the fields to create and update. + +Next, you need to check which custom fields should be created, updated, or deleted based on the input and existing custom fields. + +Replace the `TODO` with the following: + +```ts title="src/workflows/upsert-product-builder.ts" highlights={prepareCustomFieldsStepHighlights2} +// Process input fields to determine creates vs updates +input.custom_fields?.forEach((fieldData) => { + const existingField = existingCustomFields.find((f) => f.id === fieldData.id) + if (fieldData.id && existingField) { + // Update existing field + toUpdate.push({ + id: fieldData.id, + name: fieldData.name, + type: fieldData.type, + is_required: fieldData.is_required ?? false, + description: fieldData.description ?? "", + }) + } else { + // Create new field + toCreate.push({ + product_builder_id: input.product_builder_id, + name: fieldData.name, + type: fieldData.type, + is_required: fieldData.is_required ?? false, + description: fieldData.description ?? "", + }) + } +}) + +// Find fields to delete (existing but not in input) +const toDelete = existingCustomFields.filter( + (field) => !input.custom_fields?.some((f) => f.id === field.id) +) + +return new StepResponse({ + toCreate, + toUpdate, + toDelete, +}) +``` + +You loop over the `custom_fields` array in the input to determine which fields need to be created or updated, then you add them to the appropriate arrays. + +Afterwards, you find the fields that need to be deleted by checking which existing fields are not present in the input. + +Finally, you return an object that has the custom fields to create, update, and delete. + +#### createProductBuilderCustomFieldsStep + +The `createProductBuilderCustomFieldsStep` creates custom fields. + +To create the step, create the file `src/workflows/steps/create-product-builder-custom-fields.ts` with the following content: + +```ts title="src/workflows/steps/create-product-builder-custom-fields.ts" highlights={createCustomFieldsStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { PRODUCT_BUILDER_MODULE } from "../../modules/product-builder" + +export type CreateProductBuilderCustomFieldsStepInput = { + custom_fields: Array<{ + product_builder_id: string + name: string + type: string + is_required: boolean + description?: string + }> +} + +export const createProductBuilderCustomFieldsStep = createStep( + "create-product-builder-custom-fields", + async (input: CreateProductBuilderCustomFieldsStepInput, { container }) => { + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + + const createdFields = await productBuilderModuleService.createProductBuilderCustomFields( + input.custom_fields + ) + + return new StepResponse(createdFields, { + createdItems: createdFields, + }) + }, + async (compensationData, { container }) => { + if (!compensationData?.createdItems?.length) { + return + } + + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + await productBuilderModuleService.deleteProductBuilderCustomFields( + compensationData.createdItems.map((f: any) => f.id) + ) + } +) +``` + +This step receives the custom fields to create as input. + +In the step function, you create the custom fields and return them. + +In the compensation function, you delete the created custom fields if an error occurs during the workflow's execution. + +#### updateProductBuilderCustomFieldsStep + +The `updateProductBuilderCustomFieldsStep` updates existing custom fields. + +To create the step, create the file `src/workflows/steps/update-product-builder-custom-fields.ts` with the following content: + +```ts title="src/workflows/steps/update-product-builder-custom-fields.ts" highlights={updateProductBuilderCustomFieldsStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { PRODUCT_BUILDER_MODULE } from "../../modules/product-builder" + +export type UpdateProductBuilderCustomFieldsStepInput = { + custom_fields: Array<{ + id: string + name: string + type: string + is_required: boolean + description?: string + }> +} + +export const updateProductBuilderCustomFieldsStep = createStep( + "update-product-builder-custom-fields", + async (input: UpdateProductBuilderCustomFieldsStepInput, { container }) => { + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + + // Store original state for compensation + const originalFields = await productBuilderModuleService.listProductBuilderCustomFields({ + id: input.custom_fields.map((f) => f.id), + }) + + const updatedFields = await productBuilderModuleService.updateProductBuilderCustomFields( + input.custom_fields + ) + + return new StepResponse(updatedFields, { + originalItems: originalFields, + }) + }, + async (compensationData, { container }) => { + if (!compensationData?.originalItems?.length) { + return + } + + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + await productBuilderModuleService.updateProductBuilderCustomFields( + compensationData.originalItems.map((f: any) => ({ + id: f.id, + name: f.name, + type: f.type, + is_required: f.is_required, + description: f.description, + })) + ) + } +) +``` + +The step receives the custom fields to update as input. + +In the step function, you update the custom fields and return them. + +In the compensation function, you restore the custom fields to their original values if an error occurs during the workflow's execution. + +#### deleteProductBuilderCustomFieldsStep + +The `deleteProductBuilderCustomFieldsStep` deletes custom fields. + +To create the step, create the file `src/workflows/steps/delete-product-builder-custom-fields.ts` with the following content: + +```ts title="src/workflows/steps/delete-product-builder-custom-fields.ts" highlights={deleteProductBuilderCustomFieldsStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { PRODUCT_BUILDER_MODULE } from "../../modules/product-builder" + +export type DeleteProductBuilderCustomFieldsStepInput = { + custom_fields: Array<{ + id: string + product_builder_id: string + name: string + type: string + is_required: boolean + description?: string | null + }> +} + +export const deleteProductBuilderCustomFieldsStep = createStep( + "delete-product-builder-custom-fields", + async (input: DeleteProductBuilderCustomFieldsStepInput, { container }) => { + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + + await productBuilderModuleService.deleteProductBuilderCustomFields( + input.custom_fields.map((f) => f.id) + ) + + return new StepResponse(input.custom_fields, { + deletedItems: input.custom_fields, + }) + }, + async (compensationData, { container }) => { + if (!compensationData?.deletedItems?.length) { + return + } + + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + await productBuilderModuleService.createProductBuilderCustomFields( + compensationData.deletedItems.map((f: any) => ({ + id: f.id, + product_builder_id: f.product_builder_id, + name: f.name, + type: f.type, + is_required: f.is_required, + description: f.description, + })) + ) + } +) +``` + +The step receives the custom fields to delete as input. + +In the step function, you delete the custom fields and return them. + +In the compensation function, you restore the custom fields if an error occurs during the workflow's execution. + +#### prepareProductBuilderComplementaryProductsStep + +The `prepareProductBuilderComplementaryProductsStep` receives the complementary products from the workflow's input and returns which complementary products should be created or deleted. + +To create the step, create the file `src/workflows/steps/prepare-product-builder-complementary-products.ts` with the following content: + +```ts title="src/workflows/steps/prepare-product-builder-complementary-products.ts" highlights={prepareProductBuilderComplementaryProductsStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { PRODUCT_BUILDER_MODULE } from "../../modules/product-builder" + +export type PrepareProductBuilderComplementaryProductsStepInput = { + product_builder_id: string + complementary_products?: Array<{ + id?: string + product_id: string + }> +} + +export const prepareProductBuilderComplementaryProductsStep = createStep( + "prepare-product-builder-complementary-products", + async (input: PrepareProductBuilderComplementaryProductsStepInput, { container }) => { + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + + // Get existing complementary products for this product builder + const existingComplementaryProducts = await productBuilderModuleService + .listProductBuilderComplementaries({ + product_builder_id: input.product_builder_id, + }) + + // Separate operations: create and delete + const toCreate: any[] = [] + + // Process input products to determine creates + input.complementary_products?.forEach((productData) => { + const existingProduct = existingComplementaryProducts.find( + (p) => p.product_id === productData.product_id + ) + if (!existingProduct) { + // Create new complementary product + toCreate.push({ + product_builder_id: input.product_builder_id, + product_id: productData.product_id, + }) + } + }) + + // Find products to delete (existing but not in input) + const toDelete = existingComplementaryProducts.filter( + (product) => !input.complementary_products?.some( + (p) => p.product_id === product.product_id + ) + ) + + return new StepResponse({ + toCreate, + toDelete, + }) + } +) +``` + +The step receives the ID of the product builder and the complementary products to manage as input. + +In the step, you retrieve the existing complementary products for the specified product builder and determine which products need to be created or deleted based on whether it exists in the input. + +You return an object that has the complementary products to create and delete. + +#### createProductBuilderComplementaryProductsStep + +The `createProductBuilderComplementaryProductsStep` creates complementary products. + +To create the step, create the file `src/workflows/steps/create-product-builder-complementary-products.ts` with the following content: + +```ts title="src/workflows/steps/create-product-builder-complementary-products.ts" highlights={createProductBuilderComplementaryProductsStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { PRODUCT_BUILDER_MODULE } from "../../modules/product-builder" + +export type CreateProductBuilderComplementaryProductsStepInput = { + complementary_products: Array<{ + product_builder_id: string + product_id: string + }> +} + +export const createProductBuilderComplementaryProductsStep = createStep( + "create-product-builder-complementary-products", + async (input: CreateProductBuilderComplementaryProductsStepInput, { container }) => { + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + + const created = await productBuilderModuleService.createProductBuilderComplementaries( + input.complementary_products + ) + const createdArray = Array.isArray(created) ? created : [created] + + return new StepResponse(createdArray, { + createdIds: createdArray.map((p: any) => p.id), + }) + }, + async (compensationData, { container }) => { + if (!compensationData?.createdIds?.length) { + return + } + + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + await productBuilderModuleService.deleteProductBuilderComplementaries( + compensationData.createdIds + ) + } +) +``` + +The step receives the complementary products to create as input. + +In the step, you create the complementary products and return them. + +In the compensation function, you delete the created complementary products if an error occurs during the workflow's execution. + +#### deleteProductBuilderComplementaryProductsStep + +The `deleteProductBuilderComplementaryProductsStep` deletes complementary products. + +To create the step, create the file `src/workflows/steps/delete-product-builder-complementary-products.ts` with the following content: + +```ts title="src/workflows/steps/delete-product-builder-complementary-products.ts" highlights={deleteProductBuilderComplementaryProductsStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { PRODUCT_BUILDER_MODULE } from "../../modules/product-builder" + +export type DeleteProductBuilderComplementaryProductsStepInput = { + complementary_products: Array<{ + id: string + product_id: string + product_builder_id: string + }> +} + +export const deleteProductBuilderComplementaryProductsStep = createStep( + "delete-product-builder-complementary-products", + async (input: DeleteProductBuilderComplementaryProductsStepInput, { container }) => { + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + + await productBuilderModuleService.deleteProductBuilderComplementaries( + input.complementary_products.map((p) => p.id) + ) + + return new StepResponse(input.complementary_products, { + deletedItems: input.complementary_products, + }) + }, + async (compensationData, { container }) => { + if (!compensationData?.deletedItems?.length) { + return + } + + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + await productBuilderModuleService.createProductBuilderComplementaries( + compensationData.deletedItems.map((p: any) => ({ + id: p.id, + product_builder_id: p.product_builder_id, + product_id: p.product_id, + })) + ) + } +) +``` + +This step receives complementary products to delete as input. + +In the step, you delete the complementary products. + +In the compensation function, you recreate the deleted complementary products if an error occurs during the workflow's execution. + +#### prepareProductBuilderAddonsStep + +The `prepareProductBuilderAddonsStep` receives the addon products from the workflow's input and returns which addon products should be created or deleted. + +To create the step, create the file `src/workflows/steps/prepare-product-builder-addons.ts` with the following content: + +```ts title="src/workflows/steps/prepare-product-builder-addons.ts" highlights={prepareProductBuilderAddonsStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { PRODUCT_BUILDER_MODULE } from "../../modules/product-builder" + +export type PrepareProductBuilderAddonsStepInput = { + product_builder_id: string + addon_products?: Array<{ + id?: string + product_id: string + }> +} + +export const prepareProductBuilderAddonsStep = createStep( + "prepare-product-builder-addons", + async (input: PrepareProductBuilderAddonsStepInput, { container }) => { + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + + // Get existing addon associations for this product builder + const existingAddons = await productBuilderModuleService.listProductBuilderAddons({ + product_builder_id: input.product_builder_id, + }) + + // Separate operations: create, update, and delete + const toCreate: any[] = [] + + // Process input products to determine creates + input.addon_products?.forEach((productData) => { + const existingAddon = existingAddons.find( + (a) => a.product_id === productData.product_id + ) + if (!existingAddon) { + // Create new addon product + toCreate.push({ + product_builder_id: input.product_builder_id, + product_id: productData.product_id, + }) + } + }) + + // Find products to delete (existing but not in input) + const toDelete = existingAddons.filter( + (product) => !input.addon_products?.some( + (p) => p.product_id === product.product_id + ) + ) + + return new StepResponse({ + toCreate, + toDelete, + }) + } +) +``` + +The step receives the ID of the product builder and the addon products to manage as input. + +In the step, you retrieve the existing addon products for the specified product builder and determine which products need to be created or deleted based on whether it exists in the input. + +You return an object that has the addon products to create and delete. + +#### createProductBuilderAddonsStep + +The `createProductBuilderAddonsStep` creates addon products. + +To create the step, create the file `src/workflows/steps/create-product-builder-addons.ts` with the following content: + +```ts title="src/workflows/steps/create-product-builder-addons.ts" highlights={createProductBuilderAddonsStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { PRODUCT_BUILDER_MODULE } from "../../modules/product-builder" + +export type CreateProductBuilderAddonsStepInput = { + addon_products: Array<{ + product_builder_id: string + product_id: string + }> +} + +export const createProductBuilderAddonsStep = createStep( + "create-product-builder-addons", + async (input: CreateProductBuilderAddonsStepInput, { container }) => { + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + + const createdAddons = await productBuilderModuleService.createProductBuilderAddons( + input.addon_products + ) + + return new StepResponse(createdAddons, { + createdItems: createdAddons, + }) + }, + async (compensationData, { container }) => { + if (!compensationData?.createdItems?.length) { + return + } + + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + await productBuilderModuleService.deleteProductBuilderAddons( + compensationData.createdItems.map((a: any) => a.id) + ) + } +) +``` + +The step receives the addon products to create as input. + +In the step, you create the addon products and return them. + +In the compensation function, you delete the created addon products if an error occurs during the workflow's execution. + +#### deleteProductBuilderAddonsStep + +The `deleteProductBuilderAddonsStep` deletes addon products. + +To create the step, create the file `src/workflows/steps/delete-product-builder-addons.ts` with the following content: + +```ts title="src/workflows/steps/delete-product-builder-addons.ts" highlights={deleteProductBuilderAddonsStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { PRODUCT_BUILDER_MODULE } from "../../modules/product-builder" + +export type DeleteProductBuilderAddonsStepInput = { + addon_products: Array<{ + id: string + product_builder_id: string + product_id: string + }> +} + +export const deleteProductBuilderAddonsStep = createStep( + "delete-product-builder-addons", + async (input: DeleteProductBuilderAddonsStepInput, { container }) => { + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + + await productBuilderModuleService.deleteProductBuilderAddons( + input.addon_products.map((a) => a.id) + ) + + return new StepResponse(input.addon_products, { + deletedItems: input.addon_products, + }) + }, + async (compensationData, { container }) => { + if (!compensationData?.deletedItems?.length) { + return + } + + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + await productBuilderModuleService.createProductBuilderAddons( + compensationData.deletedItems.map((a: any) => ({ + id: a.id, + product_builder_id: a.product_builder_id, + product_id: a.product_id, + })) + ) + } +) +``` + +This step receives addon products to delete as input. + +In the step, you delete the addon products. + +In the compensation function, you recreate the deleted addon products if an error occurs during the workflow's execution. + +#### Create Workflow + +You now have the necessary steps to build the workflow that upserts a product builder configuration. Since the workflow is long, you'll create it in chunks. + +Start by creating the file `src/workflows/upsert-product-builder.ts` with the following content: + +```ts title="src/workflows/upsert-product-builder.ts" collapsibleLines="1-17" expandButtonLabel="Show Imports" +import { createWorkflow, parallelize, transform, when, WorkflowResponse } from "@medusajs/framework/workflows-sdk" +import { createRemoteLinkStep, dismissRemoteLinkStep } from "@medusajs/medusa/core-flows" +import { Modules } from "@medusajs/framework/utils" +import { createProductBuilderStep } from "./steps/create-product-builder" +import { prepareProductBuilderCustomFieldsStep } from "./steps/prepare-product-builder-custom-fields" +import { createProductBuilderCustomFieldsStep } from "./steps/create-product-builder-custom-fields" +import { updateProductBuilderCustomFieldsStep } from "./steps/update-product-builder-custom-fields" +import { deleteProductBuilderCustomFieldsStep } from "./steps/delete-product-builder-custom-fields" +import { prepareProductBuilderComplementaryProductsStep } from "./steps/prepare-product-builder-complementary-products" +import { createProductBuilderComplementaryProductsStep } from "./steps/create-product-builder-complementary-products" +import { deleteProductBuilderComplementaryProductsStep } from "./steps/delete-product-builder-complementary-products" +import { prepareProductBuilderAddonsStep } from "./steps/prepare-product-builder-addons" +import { createProductBuilderAddonsStep } from "./steps/create-product-builder-addons" +import { deleteProductBuilderAddonsStep } from "./steps/delete-product-builder-addons" +import { useQueryGraphStep } from "@medusajs/medusa/core-flows" +import { PRODUCT_BUILDER_MODULE } from "../modules/product-builder" + +export type UpsertProductBuilderWorkflowInput = { + product_id: string + custom_fields?: Array<{ + id?: string + name: string + type: string + is_required?: boolean + description?: string | null + }> + complementary_products?: Array<{ + id?: string + product_id: string + }> + addon_products?: Array<{ + id?: string + product_id: string + }> +} + +export const upsertProductBuilderWorkflow = createWorkflow( + "upsert-product-builder", + (input: UpsertProductBuilderWorkflowInput) => { + // TODO retrieve or create product builder + } +) +``` + +You create a workflow using the `createWorkflow` function. It accepts the workflow's unique name as a first parameter. + +It accepts as a second parameter a constructor function that holds the workflow's implementation. + +The function accepts an input object holding the details of the product builder to create or update, including its associated product ID, custom fields, complementary products, and addon products. + +The first part of the workflow is to retrieve or create the product builder configuration. So, replace the `TODO` with the following: + +```ts title="src/workflows/upsert-product-builder.ts" highlights={upsertProductBuilderWorkflowHighlights1} +const { data: existingProductBuilder } = useQueryGraphStep({ + entity: "product_builder", + fields: [ + "id", + ], + filters: { + product_id: input.product_id, + }, +}) + +const productBuilder = when({ + existingProductBuilder, + // @ts-ignore +}, ({ existingProductBuilder }) => existingProductBuilder.length === 0) + .then(() => { + const productBuilder = createProductBuilderStep({ + product_id: input.product_id, + }) + + const productBuilderLink = transform({ + productBuilder, + }, (data) => [{ + [PRODUCT_BUILDER_MODULE]: { + product_builder_id: data.productBuilder!.id, + }, + [Modules.PRODUCT]: { + product_id: data.productBuilder!.product_id, + }, + }]) + + const link = createRemoteLinkStep(productBuilderLink) + + return productBuilder + }) + +const productBuilderId = transform({ + existingProductBuilder, productBuilder, +}, (data) => data.productBuilder?.id || data.existingProductBuilder[0]!.id) + +// TODO manage custom fields +``` + +In this snippet, you: + +1. Try to retrieve the existing product builder using the `useQueryGraphStep`. + - This step uses [Query](https://docs.medusajs.com/docs/learn/fundamentals/module-links/query/index.html.md) to retrieve data across modules. +2. Use [when-then](https://docs.medusajs.com/docs/learn/fundamentals/workflows/conditions/index.html.md) to check whether the existing product builder was found. + - If there's no existing product builder, you create a new one using the `createProductBuilderStep`, then link it to the product using the `createRemoteLinkStep`. +3. Use [transform](https://docs.medusajs.com/docs/learn/fundamentals/workflows/variable-manipulation/index.html.md) to extract the product builder ID from either the existing or newly created product builder. + +In a workflow, you can't manipulate data or check conditions because Medusa stores an internal representation of the workflow on application startup. Learn more in the [Data Manipulation](https://docs.medusajs.com/docs/learn/fundamentals/workflows/variable-manipulation/index.html.md) and [Conditions](https://docs.medusajs.com/docs/learn/fundamentals/workflows/conditions/index.html.md) documentation. + +Next, you need to manage the custom fields passed in the input. Replace the new `TODO` in the workflow with the following: + +```ts title="src/workflows/upsert-product-builder.ts" highlights={upsertProductBuilderWorkflowHighlights2} +// Prepare custom fields operations +const { + toCreate: customFieldsToCreate, + toUpdate: customFieldsToUpdate, + toDelete: customFieldsToDelete, +} = prepareProductBuilderCustomFieldsStep({ + product_builder_id: productBuilderId, + custom_fields: input.custom_fields, +}) + +parallelize( + createProductBuilderCustomFieldsStep({ + custom_fields: customFieldsToCreate, + }), + updateProductBuilderCustomFieldsStep({ + custom_fields: customFieldsToUpdate, + }), + deleteProductBuilderCustomFieldsStep({ + custom_fields: customFieldsToDelete, + }) +) + +// TODO manage complementary products and addons +``` + +In this portion, you use the `prepareProductBuilderCustomFieldsStep` to determine which custom fields need to be created, updated, or deleted. + +Then, you run the `createProductBuilderCustomFieldsStep`, `updateProductBuilderCustomFieldsStep`, and `deleteProductBuilderCustomFieldsStep` in parallel to manage the custom fields. + +Next, you need to manage the complementary products passed in the input. Replace the new `TODO` in the workflow with the following: + +```ts title="src/workflows/upsert-product-builder.ts" highlights={upsertProductBuilderWorkflowHighlights3} +// Prepare complementary products operations +const { + toCreate: complementaryProductsToCreate, + toDelete: complementaryProductsToDelete, +} = prepareProductBuilderComplementaryProductsStep({ + product_builder_id: productBuilderId, + complementary_products: input.complementary_products, +}) + +const [ + createdComplementaryProducts, + deletedComplementaryProducts, +] = parallelize( + createProductBuilderComplementaryProductsStep({ + complementary_products: complementaryProductsToCreate, + }), + deleteProductBuilderComplementaryProductsStep({ + complementary_products: complementaryProductsToDelete, + }) +) + +// Create remote links for complementary products +const { + complementaryProductLinks, + deletedComplementaryProductLinks, +} = transform({ + createdComplementaryProducts, + deletedComplementaryProducts, +}, (data) => { + return { + complementaryProductLinks: data.createdComplementaryProducts.map((item) => ({ + [PRODUCT_BUILDER_MODULE]: { + product_builder_complementary_id: item.id, + }, + [Modules.PRODUCT]: { + product_id: item.product_id, + }, + })), + deletedComplementaryProductLinks: data.deletedComplementaryProducts.map((item) => ({ + [PRODUCT_BUILDER_MODULE]: { + product_builder_complementary_id: item.id, + }, + [Modules.PRODUCT]: { + product_id: item.product_id, + }, + })), + } +}) + +when({ + complementaryProductLinks, +}, ({ complementaryProductLinks }) => complementaryProductLinks.length > 0) + .then(() => { + createRemoteLinkStep(complementaryProductLinks).config({ + name: "create-complementary-product-links", + }) + }) + +when({ + deletedComplementaryProductLinks, +}, ({ deletedComplementaryProductLinks }) => deletedComplementaryProductLinks.length > 0) + .then(() => { + dismissRemoteLinkStep(deletedComplementaryProductLinks) + }) +``` + +In this portion of the workflow, you: + +- Prepare which complementary products need to be created or deleted using the `prepareProductBuilderComplementaryProductsStep`. +- Run the `createProductBuilderComplementaryProductsStep` and `deleteProductBuilderComplementaryProductsStep` in parallel to manage the complementary products. +- Prepare the links to be created or deleted between the complementary products and the Medusa products. +- Create the links for the new complementary products. +- Dismiss the links for the deleted complementary products. + +Next, you need to manage the addon products passed in the input. Replace the new `TODO` in the workflow with the following: + +```ts title="src/workflows/upsert-product-builder.ts" highlights={upsertProductBuilderWorkflowHighlights4} +// Prepare addons operations +const { + toCreate: addonsToCreate, + toDelete: addonsToDelete, +} = prepareProductBuilderAddonsStep({ + product_builder_id: productBuilderId, + addon_products: input.addon_products, +}) + +const [createdAddons, deletedAddons] = parallelize( + createProductBuilderAddonsStep({ + addon_products: addonsToCreate, + }), + deleteProductBuilderAddonsStep({ + addon_products: addonsToDelete, + }) +) + +// Create remote links for addon products +const { + addonProductLinks, + deletedAddonProductLinks, +} = transform({ + createdAddons, + deletedAddons, +}, (data) => { + return { + addonProductLinks: data.createdAddons.map((item) => ({ + [PRODUCT_BUILDER_MODULE]: { + product_builder_addon_id: item.id, + }, + [Modules.PRODUCT]: { + product_id: item.product_id, + }, + })), + deletedAddonProductLinks: data.deletedAddons.map((item) => ({ + [PRODUCT_BUILDER_MODULE]: { + product_builder_addon_id: item.id, + }, + [Modules.PRODUCT]: { + product_id: item.product_id, + }, + })), + } +}) + +when({ + addonProductLinks, +}, ({ addonProductLinks }) => addonProductLinks.length > 0) + .then(() => { + createRemoteLinkStep(addonProductLinks).config({ + name: "create-addon-product-links", + }) + }) + +when({ + deletedAddonProductLinks, +}, ({ deletedAddonProductLinks }) => deletedAddonProductLinks.length > 0) + .then(() => { + dismissRemoteLinkStep(deletedAddonProductLinks).config({ + name: "dismiss-addon-product-links", + }) + }) +// TODO retrieve and return the product builder configuration +``` + +This part of the workflow is similar to the complementary products management, but it handles addon products instead. You create and delete addon products, then create and dismiss links between them and Medusa products. + +Finally, you need to retrieve and return the product builder configuration. Replace the last `TODO` in the workflow with the following: + +```ts title="src/workflows/upsert-product-builder.ts" highlights={upsertProductBuilderWorkflowHighlights5} +const { data: productBuilders } = useQueryGraphStep({ + entity: "product_builder", + fields: [ + "id", + "product_id", + "custom_fields.*", + "complementary_products.*", + "complementary_products.product.*", + "addons.*", + "addons.product.*", + "created_at", + "updated_at", + ], + filters: { + product_id: input.product_id, + }, +}).config({ name: "get-product-builder" }) + +// @ts-ignore +return new WorkflowResponse({ + product_builder: productBuilders[0], +}) +``` + +You retrieve the product builder configuration again using `useQueryGraphStep`. + +A workflow must return an instance of `WorkflowResponse`. It receives as a parameter the data returned by the workflow, which is the product builder configuration. + +### b. Upsert Product Builder API Route + +Next, you'll create the API route that exposes the workflow's functionality to clients. + +An API route is created in a `route.ts` file under a sub-directory of the `src/api` directory. The path of the API route is the file's path relative to `src/api`. + +Refer to the [API routes](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/index.html.md) documentation to learn more about them. + +Create the file `src/api/admin/products/[id]/builder/route.ts` with the following content: + +```ts title="src/api/admin/products/[id]/builder/route.ts" highlights={builderRouteHighlights} +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework" +import { z } from "zod" +import { upsertProductBuilderWorkflow } from "../../../../../workflows/upsert-product-builder" + +export const UpsertProductBuilderSchema = z.object({ + custom_fields: z.array(z.object({ + id: z.string().optional(), + name: z.string(), + type: z.string(), + is_required: z.boolean().optional().default(false), + description: z.string().nullable().optional(), + })).optional(), + complementary_products: z.array(z.object({ + id: z.string().optional(), + product_id: z.string(), + })).optional(), + addon_products: z.array(z.object({ + id: z.string().optional(), + product_id: z.string(), + })).optional(), +}) + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { result } = await upsertProductBuilderWorkflow(req.scope) + .run({ + input: { + product_id: req.params.id, + ...req.validatedBody, + }, + }) + + res.json({ + product_builder: result.product_builder, + }) +} +``` + +First, you define a [Zod](https://zod.dev/) schema that represents the accepted request body. It includes optional custom fields, complementary products, and addon products. + +Then, you export a `POST` route handler function, which will expose a `POST` API route at `/admin/products/[id]/builder`. + +In the route handler, you execute the `upsertProductBuilderWorkflow` passing it the Medusa container, which is available in the `req.scope` property, and executing its `run` method. + +You return the product builder in the response. + +You'll test this API route later when you customize the Medusa Admin dashboard. + +#### Add Validation Middleware + +To validate the body parameters of requests sent to the API route, you need to apply a [middleware](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/middlewares/index.html.md). + +To apply a middleware to a route, create the file `src/api/middlewares.ts` with the following content: + +```ts title="src/api/middlewares.ts" highlights={middlewareHighlights} +import { + defineMiddlewares, + validateAndTransformBody, +} from "@medusajs/framework/http" +import { UpsertProductBuilderSchema } from "./admin/products/[id]/builder/route" + +export default defineMiddlewares({ + routes: [ + { + matcher: "/admin/products/:id/builder", + methods: ["POST"], + middlewares: [ + validateAndTransformBody(UpsertProductBuilderSchema), + ], + }, + ], +}) +``` + +You apply the `validateAndTransformBody` middleware to the `POST` route of the `/admin/products/:id/builder` path, passing it the Zod schema you created in the route file. + +Any request that doesn't conform to the schema will receive a 400 Bad Request response. + +Refer to the [Middlewares](https://docs.medusajs.com/docs/learn/fundamentals/api-routes/middlewares/index.html.md) documentation to learn more. + +*** + +## Step 5: Retrieve Product Builder Data API Routes + +In this step, you'll create API routes that retrieve data useful for your admin customizations later. You'll implement API routes to: + +- Retrieve a product's builder configuration. +- Retrieve products that can be added as complementary products. +- Retrieve products that can be added as addon products. + +### a. Retrieve Product Builder Configuration API Route + +The first route you'll create is for retrieving a product's builder configuration. + +You'll make the API route available at the `/admin/products/:id/builder` path. So, add the following in the same `src/api/admin/products/[id]/builder/route.ts` file: + +```ts title="src/api/admin/products/[id]/builder/route.ts" highlights={getProductBuilderHighlights} +export const GET = async ( + req: AuthenticatedMedusaRequest<{ id: string }>, + res: MedusaResponse +) => { + const query = req.scope.resolve("query") + + const { data: productBuilders } = await query.graph({ + entity: "product_builder", + fields: [ + "id", + "product_id", + "custom_fields.*", + "complementary_products.*", + "complementary_products.product.*", + "addons.*", + "addons.product.*", + "created_at", + "updated_at", + ], + filters: { + product_id: req.params.id, + }, + }) + + if (productBuilders.length === 0) { + return res.status(404).json({ + message: `Product builder configuration not found for product ID: ${req.params.id}`, + }) + } + + res.json({ + product_builder: productBuilders[0], + }) +} +``` + +Since you export a `GET` route handler function, you expose a `GET` API route at `/admin/products/:id/builder`. + +In the route handler function, you resolve [Query](https://docs.medusajs.com/docs/learn/fundamentals/module-links/query/index.html.md) to retrieve the product builder configuration for the specified product ID. You also retrieve its custom fields, complementary products, and addon products. + +You return the product builder configuration in the response. + +### b. Retrieve Complementary Products API Route + +Next, you'll create an API route that retrieves products that can be added as complementary products for another product. This is useful to allow the admin users to select complementary products when configuring a product builder. + +To create the API route, create the file `src/api/admin/products/complementary/route.ts` with the following content: + +```ts title="src/api/admin/products/complementary/route.ts" highlights={getComplementaryProductsRouteHighlights} +import { MedusaRequest, MedusaResponse } from "@medusajs/framework" +import { createFindParams } from "@medusajs/medusa/api/utils/validators" +import { z } from "zod" + +export const GetComplementaryProductsSchema = z.object({ + exclude_product_id: z.string(), +}).merge(createFindParams()) + +export const GET = async ( + req: MedusaRequest, + res: MedusaResponse +) => { + const { + exclude_product_id, + } = req.validatedQuery + + const query = req.scope.resolve("query") + + const { + data: products, + metadata, + } = await query.graph({ + entity: "product", + fields: [ + "*", + "variants.*", + ], + filters: { + id: { + $ne: exclude_product_id as string, + }, + tags: { + $or: [ + { + value: { + $eq: null, + }, + }, + { + value: { + $ne: "addon", + }, + }, + ], + }, + status: "published", + }, + pagination: req.queryConfig.pagination, + }) + + res.json({ + products, + limit: metadata?.take, + offset: metadata?.skip, + count: metadata?.count, + }) +} +``` + +You define a Zod schema that requires passing a `exclude_product_id` query parameter to filter out the current product from the list of complementary products. You merge the schema with the `createFindParams` schema to include pagination and sorting parameters. + +In the `GET` route handler, you retrieve the potential complementary products using Query. You apply the following filters on the products: + +1. Exclude the current product from the list by filtering out the `exclude_product_id`. +2. Exclude products that have the "addon" tag, as these can only be sold as addons. +3. Exclude products that are not published. + +You also apply pagination configurations using the `req.queryConfig.pagination` property. You'll learn how you can set these configurations in a bit. + +Finally, you return the list of products in the response with pagination metadata. + +#### Apply Query Validation and Configuration Middleware + +Next, you'll apply a middleware to validate the query parameters and apply pagination configurations to the API route. + +In `src/api/middlewares.ts`, add the following imports at the top of the file: + +```ts title="src/api/middlewares.ts" +import { + validateAndTransformQuery, +} from "@medusajs/framework/http" +import { GetComplementaryProductsSchema } from "./admin/products/complementary/route" +``` + +Then, add a new route object in `defineMiddlewares`: + +```ts title="src/api/middlewares.ts" +export default defineMiddlewares({ + routes: [ + // ... + { + matcher: "/admin/products/complementary", + methods: ["GET"], + middlewares: [ + validateAndTransformQuery(GetComplementaryProductsSchema, { + isList: true, + }), + ], + }, + ], +}) +``` + +You apply the `validateAndTransformQuery` middleware to the `GET` API route at `/admin/products/complementary`. The middleware accepts two parameters: + +1. The Zod schema to validate the query parameters. +2. An object of [Request Query Configurations](https://docs.medusajs.com/docs/learn/fundamentals/module-links/query#request-query-configurations/index.html.md). You enable the `isList` option to indicate that the pagination query parameters should be added as query configurations in the `req.queryConfig.pagination` object. + +### c. Retrieve Addon Products API Route + +Finally, you'll create an API route that retrieves products that can be added as addon products for another product. This is useful to allow the admin users to select addon products when configuring a product builder. + +To create the API route, create the file `src/api/admin/products/addons/route.ts` with the following content: + +```ts title="src/api/admin/products/addons/route.ts" highlights={getAddonProductsHighlights} +import { MedusaRequest, MedusaResponse } from "@medusajs/framework" + +export const GET = async ( + req: MedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve("query") + + const { + data: products, + metadata, + } = await query.graph({ + entity: "product", + fields: [ + "*", + "variants.*", + ], + filters: { + tags: { + value: "addon", + }, + status: "published", + }, + pagination: req.queryConfig.pagination, + }) + + res.json({ + products, + limit: metadata?.take, + offset: metadata?.skip, + count: metadata?.count, + }) +} +``` + +In the `GET` API route at `/admin/products/addons`, you retrieve the products that have the "addon" tag and are published. You return the products in the response with pagination data. + +#### Apply Query Configuration Middleware + +Since the API route should accept pagination query parameters, you need to apply the `validateAndTransformQuery` middleware to it. + +In `src/api/middlewares.ts`, add the following import at the top of the file: + +```ts title="src/api/middlewares.ts" +import { createFindParams } from "@medusajs/medusa/api/utils/validators" +``` + +Then, add a new route object in `defineMiddlewares`: + +```ts title="src/api/middlewares.ts" +export default defineMiddlewares({ + routes: [ + // ... + { + matcher: "/admin/products/addons", + methods: ["GET"], + middlewares: [ + validateAndTransformQuery(createFindParams(), { + isList: true, + }), + ], + }, + ], +}) +``` + +You apply the `validateAndTransformQuery` on the route to allow passing pagination query parameters, and enabling `isList` to populate the `req.queryConfig.pagination` object. + +You'll test out all of these routes in the next step. + +*** + +## Step 6: Add Admin Widget in Product Details Page + +In this step, you'll customize the Medusa Admin to allow admin users to manage a product's builder configurations. + +The Medusa Admin dashboard is customizable, allowing you to insert widgets into existing pages, or create new pages. + +Refer to the [Admin Development](https://docs.medusajs.com/docs/learn/fundamentals/admin/index.html.md) documentation to learn more. + +In this step, you'll create the components to manage a product's builder configurations, then inject a widget into the product details page to show the configurations and allow managing them. + +### a. Initialize JS SDK + +To send requests to the Medusa server, you'll use the [JS SDK](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/js-sdk/index.html.md). It's already installed in your Medusa project, but you need to initialize it before using it in your customizations. + +Create the file `src/admin/lib/sdk.ts` with the following content: + +```ts title="src/admin/lib/sdk.ts" highlights={sdkHighlights} +import Medusa from "@medusajs/js-sdk" + +export const sdk = new Medusa({ + baseUrl: process.env.MEDUSA_BACKEND_URL || "http://localhost:9000", + debug: process.env.NODE_ENV === "development", + auth: { + type: "session", + }, +}) +``` + +Learn more about the initialization options in the [JS SDK](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/js-sdk/index.html.md) reference. + +### b. Define Types + +Next, you'll define types that you'll use in your admin customizations. + +Create the file `src/admin/types.ts` with the following content: + +```ts title="src/admin/types.ts" highlights={typesHighlights} +export type ProductBuilderBase = { + id: string + product_id: string + created_at: string + updated_at: string +} + +export type CustomFieldBase = { + id: string + name: string + type: "text" | "number" + description?: string + is_required: boolean +} + +export type ComplementaryProductBase = { + id: string + product_id: string + product?: { + id: string + title: string + } +} + +export type AddonProductBase = { + id: string + product_id: string + product?: { + id: string + title: string + } +} + +// Product Builder API Response Types +export type ProductBuilderResponse = { + product_builder: ProductBuilderBase & { + custom_fields: CustomFieldBase[] + complementary_products: ComplementaryProductBase[] + addons: AddonProductBase[] + } +} + +// Form Data Types (for creating/updating) +export type CustomField = { + id?: string + name: string + type: "text" | "number" + description?: string + is_required: boolean +} + +export type ComplementaryProduct = { + id?: string + product_id: string + product?: { + id: string + title: string + } +} + +export type AddonProduct = { + id?: string + product_id: string + product?: { + id: string + title: string + } +} +``` + +You define the following types: + +- `ProductBuilderBase`: Base type for product builder configuration. +- `CustomFieldBase`: Base type for custom fields. +- `ComplementaryProductBase`: Base type for complementary products. +- `AddonProductBase`: Base type for addon products. +- `ProductBuilderResponse`: API response type for product builder configurations. +- `CustomField`: Type of custom fields in the form that creates or updates product builder configurations. +- `ComplementaryProduct`: Type of complementary products in the form that creates or updates product builder configurations. +- `AddonProduct`: Type of addon products in the form that creates or updates product builder configurations. + +### c. Custom Fields Tab Component + +To manage a product's builder configurations, you'll show a modal with tabs for custom fields, complementary products, and add-ons. + +You'll start by creating the custom fields tab component, which allows admin users to manage custom fields for a product's builder configuration. + +![Screenshot of how the custom fields tab will look like](https://res.cloudinary.com/dza7lstvk/image/upload/v1755089825/Medusa%20Resources/CleanShot_2025-08-13_at_15.56.15_2x_uwej4x.png) + +To create the component, create the file `src/admin/components/custom-fields-tab.tsx` with the following content: + +```tsx title="src/admin/components/custom-fields-tab.tsx" collapsibleLines="1-12" expandButtonLabel="Show Imports" +import { + Button, + Heading, + Input, + Label, + Select, + Checkbox, + Text, +} from "@medusajs/ui" +import { Trash } from "@medusajs/icons" +import { CustomField } from "../types" + +type CustomFieldsTabProps = { + customFields: CustomField[] + onCustomFieldsChange: (fields: CustomField[]) => void +} + +export const CustomFieldsTab = ({ + customFields, + onCustomFieldsChange, +}: CustomFieldsTabProps) => { + const addCustomField = () => { + const newFields = [ + ...customFields, + { + name: "", + type: "text" as const, + description: "", + is_required: false, + }, + ] + onCustomFieldsChange(newFields) + } + + const updateCustomField = (index: number, field: Partial) => { + const updated = [...customFields] + updated[index] = { ...updated[index], ...field } + onCustomFieldsChange(updated) + } + + const removeCustomField = (index: number) => { + const filtered = customFields.filter((_, i) => i !== index) + onCustomFieldsChange(filtered) + } + + return ( +
+
+
+ Custom Fields + +
+ + {customFields.length === 0 ? ( + No custom fields configured. + ) : ( +
+ {customFields.map((field, index) => ( +
+
+ + +
+
+
+ + updateCustomField(index, { name: e.target.value })} + placeholder="Field name" + /> +
+
+ + +
+
+
+ + updateCustomField(index, { description: e.target.value })} + placeholder="Provide helpful instructions for this field" + /> +
+
+ + updateCustomField(index, { is_required: !!checked }) + } + /> + +
+
+ ))} +
+ )} +
+
+ ) +} +``` + +This component receives the custom fields and a function to change them as an input. + +In the component, you show each custom field in its own section with fields for the name, type, description, and whether it's required. + +You also add buttons to add a new custom field or remove an existing one. + +When the admin user changes values of a custom field, creates a custom field, or deletes a custom field, you use the `onCustomFieldChange` callback to set the updated custom fields. + +### d. Complementary Products Tab Component + +Next, you'll create the complementary products tab component, which allows admin users to manage the complementary products for a product's builder configuration. + +![Screenshot of how the complementary products tab will look like](https://res.cloudinary.com/dza7lstvk/image/upload/v1755089830/Medusa%20Resources/CleanShot_2025-08-13_at_15.56.20_2x_jxix2n.png) + +Create the file `src/admin/components/complementary-products-tab.tsx` with the following content: + +```tsx title="src/admin/components/complementary-products-tab.tsx" collapsibleLines="1-13" expandButtonLabel="Show Imports" +import { AdminProduct } from "@medusajs/framework/types" +import { + Heading, + Checkbox, + createDataTableColumnHelper, + DataTable, + DataTablePaginationState, + useDataTable, +} from "@medusajs/ui" +import { useState } from "react" +import { useQuery } from "@tanstack/react-query" +import { sdk } from "../lib/sdk" +import { ComplementaryProduct } from "../types" + +type ComplementaryProductsTabProps = { + product: AdminProduct + complementaryProducts: ComplementaryProduct[] + onComplementaryProductSelection: (productId: string, checked: boolean) => void +} + +type ProductRow = { + id: string + title: string + status: string +} + +const columnHelper = createDataTableColumnHelper() + +export const ComplementaryProductsTab = ({ + product, + complementaryProducts, + onComplementaryProductSelection, +}: ComplementaryProductsTabProps) => { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 20, + }) + + // Fetch products for selection with pagination + const { data: productsData, isLoading } = useQuery({ + queryKey: ["products", "complementary", pagination], + queryFn: async () => { + const query = new URLSearchParams({ + limit: pagination.pageSize.toString(), + offset: (pagination.pageIndex * pagination.pageSize).toString(), + exclude_product_id: product.id, + }) + const response: any = await sdk.client.fetch( + `/admin/products/complementary?${query.toString()}` + ) + return { + products: response.products, + count: response.count, + } + }, + }) + + const columns = [ + columnHelper.display({ + id: "select", + header: "Select", + cell: ({ row }) => { + const isChecked = !!complementaryProducts.find( + (cp) => cp.product_id === row.original.id + ) + return ( + + onComplementaryProductSelection(row.original.id, !!checked) + } + className="my-2" + /> + ) + }, + }), + columnHelper.accessor("title", { + header: "Product", + }), + ] + + const table = useDataTable({ + data: productsData?.products || [], + columns, + rowCount: productsData?.count || 0, + getRowId: (row) => row.id, + isLoading, + pagination: { + state: pagination, + onPaginationChange: setPagination, + }, + }) + + return ( +
+ + + Complementary Products + + + + +
+ ) +} +``` + +This component receives the following props: + +- `product`: The main product being configured. +- `complementaryProducts`: A set of selected complementary product IDs. +- `onComplementaryProductSelection`: A function to handle selection changes. + +In the component, you retrieve the products using the [retrieve complementary products API route](#b-retrieve-complementary-products-api-route) you created. You show these products in a table with a checkbox for selection. + +When a product is selected or de-selected, you use the `onComplementaryProductSelection` function to update the list of selected complementary products. + +You use [Tanstack Query](https://tanstack.com/query/latest) to send requests with the JS SDK, which simplifies data fetching and caching. + +### e. Addon Products Tab Component + +Next, you'll create the last tab of the product builder configuration modal. It will allow the admin user to select addons of the product. + +![Screenshot of how the addons tab will look like](https://res.cloudinary.com/dza7lstvk/image/upload/v1755089831/Medusa%20Resources/CleanShot_2025-08-13_at_15.56.24_2x_zmryk1.png) + +To create the component, create the file `src/admin/components/addons-tab.tsx` with the following content: + +```tsx title="src/admin/components/addons-tab.tsx" collapsibleLines="1-12" expandButtonLabel="Show Imports" +import { + Heading, + Checkbox, + createDataTableColumnHelper, + DataTable, + DataTablePaginationState, + useDataTable, +} from "@medusajs/ui" +import { useState } from "react" +import { useQuery } from "@tanstack/react-query" +import { sdk } from "../lib/sdk" +import { AddonProduct } from "../types" + +type AddonsTabProps = { + addonProducts: AddonProduct[] + onAddonProductSelection: (productId: string, checked: boolean) => void +} + +type ProductRow = { + id: string + title: string + status: string +} + +const columnHelper = createDataTableColumnHelper() + +export const AddonsTab = ({ + addonProducts, + onAddonProductSelection, +}: AddonsTabProps) => { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 20, + }) + + // Fetch addon products with pagination + const { data: addonsData, isLoading } = useQuery({ + queryKey: ["products", "addon", pagination], + queryFn: async () => { + const response: any = await sdk.client.fetch( + `/admin/products/addons?limit=${pagination.pageSize}&offset=${pagination.pageIndex * pagination.pageSize}` + ) + return { + addons: response.products || [], + count: response.count || 0, + } + }, + }) + + const columns = [ + columnHelper.display({ + id: "select", + header: "Select", + cell: ({ row }) => { + const isChecked = !!addonProducts.find( + (ap) => ap.product_id === row.original.id + ) + return ( + + onAddonProductSelection(row.original.id, !!checked) + } + className="my-2" + /> + ) + }, + }), + columnHelper.accessor("title", { + header: "Product", + }), + ] + + const tableData = addonsData?.addons || [] + + const table = useDataTable({ + data: tableData, + columns, + rowCount: addonsData?.count || 0, + getRowId: (row) => row.id, + isLoading, + pagination: { + state: pagination, + onPaginationChange: setPagination, + }, + }) + + return ( +
+ + + Addon Products + + + + +
+ ) +} +``` + +The component receives the following props: + +- `addonProducts`: A set of selected addon product IDs. +- `onAddonProductSelection`: A callback function to handle addon product selection changes. + +In the component, you retrieve the products using the [retrieve addon products API route](#c-retrieve-addon-products-api-route) you created. You show these products in a table with a checkbox for selection. + +When a product is selected or de-selected, you use the `onAddonProductSelection` function to update the list of selected addon products. + +### d. Product Builder Configurations Modal + +Now that you have the components for managing custom fields, complementary products, and add-ons, you'll create a modal component that wraps these tabs in a modal. + +Create the file `src/admin/components/product-builder-modal.tsx` with the following content: + +```tsx title="src/admin/components/product-builder-modal.tsx" collapsibleLines="1-21" expandButtonLabel="Show Imports" +import { + Button, + FocusModal, + Heading, + toast, + ProgressTabs, +} from "@medusajs/ui" +import { useState, useEffect } from "react" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { sdk } from "../lib/sdk" +import { + CustomField, + ComplementaryProduct, + ProductBuilderResponse, + AddonProduct, +} from "../types" +import { AdminProduct } from "@medusajs/framework/types" +import { ComplementaryProductsTab } from "./complementary-products-tab" +import { AddonsTab } from "./addons-tab" +import { CustomFieldsTab } from "./custom-fields-tab" + +type ProductBuilderModalProps = { + open: boolean + onOpenChange: (open: boolean) => void + product: AdminProduct + initialData?: ProductBuilderResponse["product_builder"] + onSuccess: () => void +} + +export const ProductBuilderModal = ({ + open, + onOpenChange, + product, + initialData, + onSuccess, +}: ProductBuilderModalProps) => { + const [customFields, setCustomFields] = useState([]) + const [complementaryProducts, setComplementaryProducts] = useState([]) + const [addonProducts, setAddonProducts] = useState([]) + const [currentTab, setCurrentTab] = useState("custom-fields") + + const queryClient = useQueryClient() + + // Helper function to determine tab status + const getTabStatus = (tabName: string): "not-started" | "in-progress" | "completed" => { + const isCurrentTab = currentTab === tabName + switch (tabName) { + case "custom-fields": + return customFields.length > 0 ? isCurrentTab ? + "in-progress" : "completed" : + "not-started" + case "complementary": + return complementaryProducts.length > 0 ? isCurrentTab ? + "in-progress" : "completed" : + "not-started" + case "addons": + return addonProducts.length > 0 ? isCurrentTab ? + "in-progress" : "completed" : + "not-started" + default: + return "not-started" + } + } + + // Load initial data when modal opens + useEffect(() => { + setCustomFields(initialData?.custom_fields || []) + setComplementaryProducts(initialData?.complementary_products || []) + setAddonProducts(initialData?.addons || []) + + // Reset to first tab when modal opens + setCurrentTab("custom-fields") + }, [open, initialData]) + + const { mutateAsync: saveConfiguration, isPending: isSaving } = useMutation({ + mutationFn: async (data: any) => { + return await sdk.client.fetch(`/admin/products/${product.id}/builder`, { + method: "POST", + body: data, + }) + }, + onSuccess: () => { + toast.success("Builder configuration saved successfully") + queryClient.invalidateQueries({ + queryKey: ["product-builder", product.id], + }) + onSuccess() + }, + onError: (error: any) => { + toast.error(`Failed to save configuration: ${error.message}`) + }, + }) + + const handleSave = async () => { + try { + await saveConfiguration({ + custom_fields: customFields, + complementary_products: complementaryProducts.map((cp) => ({ + id: cp.id, + product_id: cp.product_id, + })), + addon_products: addonProducts.map((ap) => ({ + id: ap.id, + product_id: ap.product_id, + })), + }) + } catch (error) { + toast.error(`Error saving configuration: ${error instanceof Error ? error.message : "Unknown error"}`) + } + } + + const handleComplementarySelection = (productId: string, checked: boolean) => { + setComplementaryProducts((prev) => { + if (checked) { + return [ + ...prev, + { + product_id: productId, + }, + ] + } + + return prev.filter((cp) => cp.product_id !== productId) + }) + } + + const handleAddonSelection = (productId: string, checked: boolean) => { + setAddonProducts((prev) => { + if (checked) { + return [ + ...prev, + { + product_id: productId, + }, + ] + } + + return prev.filter((ap) => ap.product_id !== productId) + }) + } + + const handleNextTab = () => { + if (currentTab === "custom-fields") { + setCurrentTab("complementary") + } else if (currentTab === "complementary") { + setCurrentTab("addons") + } + } + + const isLastTab = currentTab === "addons" + + // TODO render modal +} +``` + +The `ProductBuilderModal` accepts the following props: + +- `open`: Whether the modal is open. +- `onOpenChange`: Function to change the open state. +- `product`: The product being configured. +- `initialData`: The initial data for the product builder. +- `onSuccess`: Function to execute when the configuration is saved successfully. + +In the component, you define the following variables and functions: + +- `customFields`: Stores the custom fields entered by the admin. +- `complementaryProducts`: Stores the complementary products selected by the admin. +- `addonProducts`: Stores the addon products selected by the admin. +- `currentTab`: The current active tab. +- `queryClient`: The Tanstack Query client which is useful to refetch data. +- `getTabStatus`: A function to get the status of each tab. +- `saveConfiguration`: A mutation to save the configuration when the admin submits them. +- `handleSave`: A function that executes the mutation to save the configurations. +- `handleComplementarySelection`: A function to update the selected complementary products. +- `handleAddonSelection`: A function to update the selected addon products. +- `handleNextTab`: A function to open the next tab. +- `isLastTab`: A boolean indicating if the current tab is the last tab. + +Next, to render the form, replace the `TODO` in the component with the following: + +```tsx title="src/admin/components/product-builder-modal.tsx" +return ( + + + + Builder Configuration + + + + + + Custom Fields + + + Complementary Products + + + Addon Products + + + + + + + + + + + + + + + + + +
+
+ + +
+
+
+
+
+) +``` + +You display a [Focus Modal](https://docs.medusajs.com/ui/components/focus-modal/index.html.md) that shows the tabs with each of their content. + +The modal has a button to move between tabs, then save the changes when the admin user reaches the last tab. + +### e. Add Widget to Product Details Page + +Finally, you'll create the widget that will be injected to the product details page. + +Create the file `src/admin/widgets/product-builder-widget.tsx` with the following content: + +```tsx title="src/admin/widgets/product-builder-widget.tsx" collapsibleLines="1-12" expandButtonLabel="Show Imports" +import { defineWidgetConfig } from "@medusajs/admin-sdk" +import { Container, Heading, Text, Button } from "@medusajs/ui" +import { useQuery } from "@tanstack/react-query" +import { sdk } from "../lib/sdk" +import { + DetailWidgetProps, + AdminProduct, +} from "@medusajs/framework/types" +import { useState } from "react" +import { ProductBuilderModal } from "../components/product-builder-modal" +import { ProductBuilderResponse } from "../types" + +const ProductBuilderWidget = ({ + data: product, +}: DetailWidgetProps) => { + const [modalOpen, setModalOpen] = useState(false) + + const { data, isLoading, refetch } = useQuery({ + queryFn: () => sdk.client.fetch(`/admin/products/${product.id}/builder`), + queryKey: ["product-builder", product.id], + retry: false, + }) + + const formatSummary = (items: any[], getTitle: (item: any) => string) => { + if (!items || items.length === 0) {return "-"} + if (items.length === 1) {return getTitle(items[0])} + return `${getTitle(items[0])} + ${items.length - 1} more` + } + + const customFieldsSummary = formatSummary( + data?.product_builder?.custom_fields || [], + (field) => field.name + ) + + const complementaryProductsSummary = formatSummary( + data?.product_builder?.complementary_products || [], + (item) => item.product?.title || "Unnamed Product" + ) + + const addonsSummary = formatSummary( + data?.product_builder?.addons || [], + (item) => item.product?.title || "Unnamed Product" + ) + + return ( + <> + +
+ Builder Configuration + +
+
+ {isLoading ? ( + Loading... + ) : ( + <> +
+ + Custom Fields + + + + {customFieldsSummary} + +
+
+ + Complementary Products + + + + {complementaryProductsSummary} + +
+
+ + Addon Products + + + + {addonsSummary} + +
+ + )} +
+
+ + { + refetch() + setModalOpen(false) + }} + /> + + ) +} + +export const config = defineWidgetConfig({ + zone: "product.details.side.after", +}) + +export default ProductBuilderWidget +``` + +A widget file must export: + +- A default React component. This component renders the widget's UI. +- A `config` object created with `defineWidgetConfig` from the Admin SDK. It accepts an object with the `zone` property that indicates where the widget will be rendered in the Medusa Admin dashboard. + +In the widget's component, you retrieve the product builder configuration of the current product, if available. Then, you show a summary of the configurations, with a button to edit the configurations. + +When the edit button is clicked, the product builder modal is shown with the tabs for custom fields, complementary products, and addon products. + +### Test the Admin Widget + +To test out the admin widget for product builder configurations: + +1. Start the Medusa application with the following command: + +```bash npm2yarn +npm run dev +``` + +2. Open the Medusa Admin dashboard at `localhost:9000/app` and login. +3. Go to Settings -> Product Tags. +4. Create a tag with the value `addon`. +5. Go back to the Products page, and choose an existing product to mark as an addon. +6. Change the product's tag from the Organize section. +7. Go back to the Products page, and choose an existing product to manage its builder configurations. +8. Scroll down to the end of the product's details page. You'll find a new "Builder Configuration" section. This is the widget you inserted. + +![Builder configuration widget in the product details page](https://res.cloudinary.com/dza7lstvk/image/upload/v1755096182/Medusa%20Resources/CleanShot_2025-08-13_at_17.30.06_2x_hnfsdl.png) + +9. Click on the Edit button to edit the configurations. +10. Add custom fields such as engravings, select complementary products such as keyboard, and add add-ons like a warranty. +11. Once you're done, click on the "Save Configuration" button. The modal will be closed and you can see the updated configurations in the widget. + +![Updated builder configuration data showing in the widget](https://res.cloudinary.com/dza7lstvk/image/upload/v1755096296/Medusa%20Resources/CleanShot_2025-08-13_at_17.44.45_2x_zqonoy.png) + +*** + +## Step 7: Customize Product Page on Storefront + +In this step, you'll customize the product details page on the storefront to show the product builder configurations. + +Alongside the variant options like color and size, which are already available in Medusa, you'll show: + +- The custom fields, allowing the customer to enter their values. +- The complementary products, allowing the customer to add them to the cart alongside the main product. +- The addon products, allowing the customer to add them to the cart as part of the main product. + +The Next.js Starter Storefront was installed in a separate directory from Medusa. The directory's name is `{your-project}-storefront`. + +So, if your Medusa application's directory is `medusa-product-builder`, you can find the storefront by going back to the parent directory and changing to the `medusa-product-builder-storefront` directory: + +```bash +cd ../medusa-product-builder-storefront # change based on your project name +``` + +### a. Define Types for Product Builder Configurations + +You'll start by defining types that you'll use in your storefront customizations. + +In `src/types/global.ts`, add the following import at the top of the file: + +```ts title="src/types/global.ts" badgeLabel="Storefront" badgeColor="blue" +import { + StoreProduct, +} from "@medusajs/types" +``` + +Then, add the following type definitions at the end of the file: + +```ts title="src/types/global.ts" highlights={storefrontTypesHighlights} badgeLabel="Storefront" badgeColor="blue" +export type ProductBuilderCustomField = { + id: string + name: string + type: "text" | "number" + description?: string + is_required: boolean +} + +export type ProductBuilderComplementaryProduct = { + id: string + product: StoreProduct +} + +export type ProductBuilderAddon = { + id: string + product: StoreProduct +} + +export type ProductBuilder = { + id: string + product_id: string + custom_fields: ProductBuilderCustomField[] + complementary_products: ProductBuilderComplementaryProduct[] + addons: ProductBuilderAddon[] +} + +// Extended Product Type with Product Builder +export type ProductWithBuilder = StoreProduct & { + product_builder?: ProductBuilder +} + +// Product Builder Configuration Types +export type CustomFieldValue = { + field_id: string + value: string | number +} + +export type ComplementarySelection = { + product_id: string + variant_id: string + title: string + thumbnail?: string + price: number +} + +export type AddonSelection = { + product_id: string + variant_id: string + title: string + thumbnail?: string + price: number + quantity: number +} + +export type BuilderConfiguration = { + custom_fields: CustomFieldValue[] + complementary_products: ComplementarySelection[] + addons: AddonSelection[] +} +``` + +You define the following types: + +- `ProductBuilderCustomField`: A custom field in a product builder configuration. +- `ProductBuilderComplementaryProduct`: A complementary product in a product builder configuration. +- `ProductBuilderAddon`: An add-on product in a product builder configuration. +- `ProductBuilder`: The main product builder configuration object. +- `ProductWithBuilder`: A Medusa product with an associated product builder configuration. +- `CustomFieldValue`: A value entered by the customer for a custom field. +- `ComplementarySelection`: A selected complementary product in a product builder configuration. +- `AddonSelection`: A selected add-on product in a product builder configuration. +- `BuilderConfiguration`: The overall builder configuration chosen by the customer for a product. + +### b. Retrieve Product Builder Configuration + +Next, you need to retrieve the builder configuration for a product when the customer views its details page. + +Since you've defined a link between the product and its builder configuration, you can retrieve the builder configuration of a product by specifying it in the `fields` query parameter of the [List Products API Route](https://docs.medusajs.com/api/store#products_getproducts). + +In `src/lib/data/products.ts`, add the following import at the top of the file: + +```ts title="src/lib/data/products.ts" badgeLabel="Storefront" badgeColor="blue" +import { ProductWithBuilder } from "../../types/global" +``` + +Then, change the return type of the `listProducts` function: + +```ts title="src/lib/data/products.ts" badgeLabel="Storefront" badgeColor="blue" highlights={[["6"]]} +export const listProducts = async ({ + // ... +}: { + // ... +}): Promise<{ + response: { products: ProductWithBuilder[]; count: number } + // ... +}> => { + // ... +} +``` + +Next, find the `sdk.client.fetch` call inside the `listProducts` function and change its type argument: + +```ts title="src/lib/data/products.ts" badgeLabel="Storefront" badgeColor="blue" highlights={[["2"]]} +return sdk.client + .fetch<{ products: ProductWithBuilder[]; count: number }>( + // ... + ) +``` + +Next, find the `fields` query parameter and add to it the product builder data: + +```ts title="src/lib/data/products.ts" badgeLabel="Storefront" badgeColor="blue" highlights={[["7"]]} +return sdk.client + .fetch<{ products: ProductWithBuilder[]; count: number }>( + `/store/products`, + { + query: { + fields: + "*variants.calculated_price,+variants.inventory_quantity,+metadata,+tags,*product_builder,*product_builder.custom_fields,*product_builder.complementary_products,*product_builder.complementary_products.product,*product_builder.complementary_products.product.variants,*product_builder.addons,*product_builder.addons.product,*product_builder.addons.product.variants", + // ... + }, + // ... + } + ) +``` + +You retrieve for each product its builder configurations, its custom fields, complementary products, and add-on products. You also retrieve the product and variant details of the complementary and addon products. + +Finally, change the return type of the `listProductsWithSort` to also include the product builder data: + +```ts title="src/lib/data/products.ts" badgeLabel="Storefront" badgeColor="blue" highlights={[["6"]]} +export const listProductsWithSort = async ({ + // ... +}: { + // ... +}): Promise<{ + response: { products: ProductWithBuilder[]; count: number } + // ... +}> => { + // ... +} +``` + +### c. Add Product Builder Configuration Utilities + +Next, you'll add utility functions that are useful in your customizations. + +Create the file `src/lib/util/product-builder.ts` with the following content: + +```ts title="src/lib/util/product-builder.ts" badgeLabel="Storefront" badgeColor="blue" highlights={productBuilderUtilityHighlights} +import { ProductWithBuilder, ProductBuilder, LineItemWithBuilderMetadata } from "../../types/global" + +// Utility function to check if a product has builder configuration +export const hasProductBuilder = ( + product: ProductWithBuilder +): product is ProductWithBuilder & { product_builder: ProductBuilder } => { + return !!product.product_builder +} + +// Utility function to check if a product has custom fields +export const hasCustomFields = ( + product: ProductWithBuilder +): boolean => { + return hasProductBuilder(product) && product.product_builder.custom_fields.length > 0 +} + +// Utility function to check if a product has complementary products +export const hasComplementaryProducts = ( + product: ProductWithBuilder +): boolean => { + return hasProductBuilder(product) && product.product_builder.complementary_products.length > 0 +} + +// Utility function to check if a product has addons +export const hasAddons = ( + product: ProductWithBuilder +): boolean => { + return hasProductBuilder(product) && product.product_builder.addons.length > 0 +} +``` + +You define the following utilities: + +- `hasProductBuilder`: Checks if a product has a product builder configuration. +- `hasCustomFields`: Checks if a product has custom fields. +- `hasComplementaryProducts`: Checks if a product has complementary products. +- `hasAddons`: Checks if a product has addons. + +### d. Implement Product Builder Configuration Component + +In this section, you'll implement the component that will show the product builder configurations on the product details page. It will show inputs for custom fields, and variant selection for complementary and addon products. + +![Screenshot of how the product builder configuration component will look like](https://res.cloudinary.com/dza7lstvk/image/upload/v1755098570/Medusa%20Resources/CleanShot_2025-08-13_at_18.22.29_2x_zdtjlk.png) + +You'll first create the `VariantSelectionRow` component that you'll use to show the complementary and addon product variants. + +Create the file `src/modules/products/components/product-builder-config/variant-selector.tsx` with the following content: + +```tsx title="src/modules/products/components/product-builder-config/variant-selector.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={variantSelectorHighlights} +"use client" + +import { HttpTypes } from "@medusajs/types" +import { + Badge, + Text, +} from "@medusajs/ui" +import { getProductPrice } from "../../../../lib/util/get-product-price" + +type VariantSelectionRowProps = { + variant: HttpTypes.StoreProductVariant + product: HttpTypes.StoreProduct + isSelected: boolean + isLoading: boolean + onToggle: (productId: string, variantId: string, title: string, thumbnail: string | undefined, price: number) => void +} + +const VariantSelector: React.FC = ({ + variant, + product, + isSelected, + isLoading, + onToggle, +}) => { + const { + calculated_price: price = 0, + calculated_price_number: priceNumber = 0, + } = getProductPrice({ + product, + variantId: variant.id, + }).variantPrice || {} + + const inStock = !variant.manage_inventory || variant.allow_backorder || ( + variant.manage_inventory && (variant.inventory_quantity || 0) > 0 + ) + + return ( + + ) +} + +export default VariantSelector +``` + +This component accepts the following props: + +- `variant`: The variant being displayed. +- `product`: The product that the variant belongs to. +- `isSelected`: Whether the variant is selected. +- `isLoading`: Whether the variant's price is currently being loaded. +- `onToggle`: A function to toggle the variant selection. + +In the component, you show the variant's details and allow customers to toggle its selection. + +Next, you'll create the component that will show the product builder. + +Create the file `src/modules/products/components/product-builder-config/index.tsx` with the following content: + +```tsx title="src/modules/products/components/product-builder-config/index.tsx" collapsibleLines="1-25" expandButtonLabel="Show Imports" badgeLabel="Storefront" badgeColor="blue" highlights={productBuilderConfigHighlights} +"use client" + +import { useState, useEffect } from "react" +import { HttpTypes } from "@medusajs/types" +import { + Text, + Input, +} from "@medusajs/ui" +import Divider from "@modules/common/components/divider" +import { + ProductWithBuilder, + BuilderConfiguration, + CustomFieldValue, + ComplementarySelection, + AddonSelection, +} from "../../../../types/global" +import { + hasProductBuilder, + hasCustomFields, + hasComplementaryProducts, + hasAddons, +} from "@lib/util/product-builder" +import { listProducts } from "@lib/data/products" +import VariantSelector from "./variant-selector" + +type ProductBuilderConfigProps = { + product: ProductWithBuilder + countryCode: string + onConfigurationChange: (config: BuilderConfiguration) => void + onValidationChange: (isValid: boolean) => void +} + +const ProductBuilderConfig: React.FC = ({ + product, + countryCode, + onConfigurationChange, + onValidationChange, +}) => { + // Configuration state + const [customFields, setCustomFields] = useState([]) + const [complementaryProducts, setComplementaryProducts] = useState([]) + const [addons, setAddons] = useState([]) + + // UI state + const [isLoadingPrices, setIsLoadingPrices] = useState(false) + const [productPrices, setProductPrices] = useState>(new Map()) + + // Early return if no product builder + if (!hasProductBuilder(product)) { + return null + } + + const builder = product.product_builder + + // Custom field handlers + const handleCustomFieldChange = (fieldId: string, value: string | number) => { + setCustomFields((prev) => { + const existing = prev.find((f) => f.field_id === fieldId) + if (existing) { + return prev.map((f) => { + return f.field_id === fieldId ? { ...f, value } : f + }) + } + return [...prev, { field_id: fieldId, value }] + }) + } + + // Complementary product handlers + const handleComplementaryToggle = ( + productId: string, + variantId: string, + title: string, + thumbnail: string | undefined, + price: number + ) => { + setComplementaryProducts((prev) => { + const prevIndex = prev.findIndex((p) => p.variant_id === variantId) + if (prevIndex !== -1) { + return [...prev].splice(prevIndex, 1) + } + return [...prev, { product_id: productId, variant_id: variantId, title, thumbnail, price }] + }) + } + + // Addon handlers + const handleAddonToggle = ( + productId: string, + variantId: string, + title: string, + thumbnail: string | undefined, + price: number + ) => { + setAddons((prev) => { + const prevIndex = prev.findIndex((p) => p.variant_id === variantId) + if (prevIndex !== -1) { + return [...prev].splice(prevIndex, 1) + } + return [...prev, { product_id: productId, variant_id: variantId, title, thumbnail, price, quantity: 1 }] + }) + } + + const showCustomFields = hasCustomFields(product) + const showComplementaryProducts = hasComplementaryProducts(product) + const showAddons = hasAddons(product) + + // TODO add useEffect statements +} + +export default ProductBuilderConfig +``` + +The `ProductBuilderConfig` component accepts the following props: + +- `product`: The product being configured. +- `countryCode`: The country code for pricing and availability. +- `onConfigurationChange`: Callback for when the configuration changes. +- `onValidationChange`: Callback for when the validation state changes. + +In the component, you define the following variables and functions: + +- `customFields`: Stores custom fields' values. +- `complementaryProducts`: Stores selected complementary product details. +- `addons`: Stores selected addon product details. +- `isLoadingPrices`: Indicates if the product prices are being loaded. +- `productPrices`: Stores the loaded product prices. +- `builder`: The product builder configuration. +- `handleCustomFieldChange`: Updates the custom fields state. +- `handleComplementaryToggle`: Toggles the selection of complementary products. +- `handleAddonToggle`: Toggles the selection of addons. + +Next, you'll add a `useEffect` statements that call the `onConfigurationChange` and `onValidationChange` callbacks when the configuration changes. Replace the `TODO` with the following: + +```tsx title="src/modules/products/components/product-builder-config/index.tsx" badgeLabel="Storefront" badgeColor="blue" +// Update configuration when any field changes +useEffect(() => { + onConfigurationChange({ + custom_fields: customFields, + complementary_products: complementaryProducts, + addons: addons, + }) +}, [customFields, complementaryProducts, addons, onConfigurationChange]) + +// Validate required fields and notify parent +useEffect(() => { + // Check required custom fields + const requiredCustomFields = builder.custom_fields.filter((field) => field.is_required) + const customFieldsValid = requiredCustomFields.every((field) => { + const fieldValue = customFields.find((cf) => cf.field_id === field.id)?.value + return fieldValue !== undefined && fieldValue !== "" && fieldValue !== 0 + }) + + onValidationChange(customFieldsValid) +}, [customFields, builder, onValidationChange]) + +// TODO add more useEffect statements +``` + +You add a `useEffect` call that triggers the `onConfigurationChange` callback when configurations are updated, and another that validates the custom fields and triggers the `onValidationChange` callback when the validation state changes. + +You'll need one more `useEffect` statement that loads the prices of complementary and addon product variants. Replace the `TODO` with the following: + +```tsx title="src/modules/products/components/product-builder-config/index.tsx" badgeLabel="Storefront" badgeColor="blue" +// Fetch product prices for complementary products and addons +useEffect(() => { + const fetchProductPrices = async () => { + const productIds = new Set([ + ...builder.complementary_products.map((comp) => comp.product.id!), + ...builder.addons.map((addon) => addon.product.id!), + ]) + + if (productIds.size === 0) { + return + } + + setIsLoadingPrices(true) + + try { + // Fetch all products with their pricing information + const { response } = await listProducts({ + queryParams: { + id: Array.from(productIds), + limit: productIds.size, + }, + countryCode, + }) + + const priceMap = new Map() + response.products.forEach((product) => { + if (product.id) { + priceMap.set(product.id, product) + } + }) + + setProductPrices(priceMap) + } catch (error) { + console.error("Error fetching product prices:", error) + } finally { + setIsLoadingPrices(false) + } + } + + fetchProductPrices() +}, [builder.complementary_products, builder.addons, countryCode]) + +// TODO add return statement +``` + +This `useEffect` hook is triggered whenever the country code, complementary products, or addons change, ensuring that the latest pricing information is fetched for the selected products. + +You fetch the prices using the `listProducts` function, and you store the prices in a map. You'll use this map to display the prices of complementary and addon product variants. + +Finally, you need to add a `return` statement to the `ProductBuilderConfig` component. Replace the `TODO` with the following: + +```tsx title="src/modules/products/components/product-builder-config/index.tsx" badgeLabel="Storefront" badgeColor="blue" +return ( +
+ {/* Custom Fields Section */} + {showCustomFields && ( + <> +
+ {builder.custom_fields.map((field) => { + const currentValue = customFields.find((f) => f.field_id === field.id)?.value || "" + + return ( +
+
+ {field.name} + {field.is_required && ( + * + )} +
+ {field.description && ( + + {field.description} + + )} + + handleCustomFieldChange( + field.id, + field.type === "number" ? parseFloat(e.target.value) || 0 : e.target.value + )} + placeholder={`Enter ${field.name.toLowerCase()}`} + /> +
+ ) + })} +
+ + + )} + + {/* Complementary Products Section */} + {showComplementaryProducts && ( + <> +
+ {builder.complementary_products + .map((compProduct) => { + const product = compProduct.product + const productWithPrices = productPrices.get(product.id!) + + return ( +
+ Add a {product.title} + + Complete your setup with perfectly matched accessories and essentials + + +
+ {(productWithPrices?.variants || product.variants || []).map((variant) => { + const isSelected = complementaryProducts.some((p) => p.variant_id === variant.id) + + return ( + + ) + })} +
+
+ ) + })} +
+ + + )} + + {/* Addons Section */} + {showAddons && ( + <> +
+
+ Protect & Enhance Your Purchase +
+ + Add peace of mind with premium features + + +
+ {builder.addons + .map((addon) => { + const product = addon.product + const productWithPrices = productPrices.get(product.id!) + + return ( +
+ {(productWithPrices?.variants || product.variants || []).map((variant) => { + const isSelected = addons.some((a) => a.variant_id === variant.id) + + return ( + + ) + })} +
+ ) + })} +
+
+ {/* Only add separator if not the last section */} + {isLoadingPrices && } + + )} + + {/* Loading State */} + {isLoadingPrices && ( +
+ Loading prices... +
+ )} + + {(showCustomFields || showComplementaryProducts || showAddons) && } +
+) +``` + +You display a separate section for each custom field, complementary product, and addon in the product builder configuration. You also use the `VariantSelector` component to display the variants of each complementary and addon product. + +### e. Modify Price Component to Include Builder Prices + +When a customer chooses complementary and addon products, the price shown on the product page should reflect that selection. So, you need to modify the pricing component to accept the builder configuration, and update the displayed price accordingly. + +In `src/modules/products/components/product-price/index.tsx`, add the followng imports at the top of the file: + +```tsx title="src/modules/products/components/product-price/index.tsx" badgeLabel="Storefront" badgeColor="blue" +import { BuilderConfiguration } from "../../../../types/global" +import { convertToLocale } from "@lib/util/money" +``` + +Then, replace the `ProductPrice` component with the following: + +```tsx title="src/modules/products/components/product-price/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={productPriceHighlights} +export default function ProductPrice({ + product, + variant, + builderConfig, +}: { + product: HttpTypes.StoreProduct + variant?: HttpTypes.StoreProductVariant + builderConfig?: BuilderConfiguration | null +}) { + const { cheapestPrice, variantPrice } = getProductPrice({ + product, + variantId: variant?.id, + }) + + const selectedPrice = variant ? variantPrice : cheapestPrice + + // Calculate total price including builder configuration + const calculateTotalPrice = () => { + if (!selectedPrice) {return null} + + let totalPrice = selectedPrice.calculated_price_number || 0 + let totalOriginalPrice = selectedPrice.original_price_number || selectedPrice.calculated_price_number || 0 + + if (builderConfig) { + // Add complementary products prices + builderConfig.complementary_products.forEach((comp) => { + totalPrice += comp.price || 0 + totalOriginalPrice += comp.price || 0 + }) + + // Add addons prices + builderConfig.addons.forEach((addon) => { + const addonPrice = (addon.price || 0) * (addon.quantity || 1) + totalPrice += addonPrice + totalOriginalPrice += addonPrice + }) + } + + const currencyCode = selectedPrice.currency_code || "USD" + + return { + calculated_price_number: totalPrice, + original_price_number: totalOriginalPrice, + calculated_price: convertToLocale({ + amount: totalPrice, + currency_code: currencyCode, + }), + original_price: convertToLocale({ + amount: totalOriginalPrice, + currency_code: currencyCode, + }), + price_type: selectedPrice.price_type, + percentage_diff: selectedPrice.percentage_diff, + } + } + + const finalPrice = calculateTotalPrice() + + if (!finalPrice) { + return
+ } + + return ( +
+ + {!variant && "From "} + + {finalPrice.calculated_price} + + + {finalPrice.price_type === "sale" && ( + <> +

+ Original: + + {finalPrice.original_price} + +

+ + -{finalPrice.percentage_diff}% + + + )} +
+ ) +} +``` + +You make the following key changes: + +- Add the `builderConfig` prop to the `ProductPrice` component. +- Add a `calculateTotalPrice` function to compute the total price including the builder configuration. +- Remove the existing condition on `selectedPrice`, and replace it instead with a condition on `finalPrice`. The condition's body is still the same. +- Modify the return statement to use `finalPrice` instead of `selectedPrice`. + +### f. Display Product Builder on Product Page + +Finally, to display the product builder component on the product details page, you need to modify two components: + +- `ProductActions` that displays the product variant options with an add-to-cart button. +- `MobileActions` that displays the product variant options in a mobile-friendly format. + +#### Customize ProductActions + +You'll start with modifying the `ProductActions` component to include the product builder. + +In `src/modules/products/components/product-actions/index.tsx`, add the following imports: + +```tsx title="src/modules/products/components/product-actions/index.tsx" badgeLabel="Storefront" badgeColor="blue" +import ProductBuilderConfig from "../product-builder-config" +import { ProductWithBuilder, BuilderConfiguration } from "../../../../types/global" +import { hasProductBuilder } from "@lib/util/product-builder" +``` + +Next, change the type of the `product` prop to `ProductWithBuilder`: + +```tsx title="src/modules/products/components/product-actions/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["2"]]} +type ProductActionsProps = { + product: ProductWithBuilder + // ... +} +``` + +Then, in the `ProductActions` component, add the following state variables and `useEffect` hook: + +```tsx title="src/modules/products/components/product-actions/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={productActionHighlights} +export default function ProductActions({ + product, + disabled, +}: ProductActionsProps) { + // ... + const [builderConfig, setBuilderConfig] = useState(null) + const [isBuilderConfigValid, setIsBuilderConfigValid] = useState(true) + + // Initialize validation state for products without builder + useEffect(() => { + if (!hasProductBuilder(product)) { + setIsBuilderConfigValid(true) + } + }, [product]) + + // ... +} +``` + +You define two variables: + +- `builderConfig`: Holds the configuration for the product builder, if it exists. +- `isBuilderConfigValid`: Tracks the validity of the builder configuration. + +You also add a `useEffect` hook to initialize the builder configuration state when the product changes. + +Next, you'll make updates to the `return` statement. Find the `ProductPrice` usage in the `return` statement and replace it with the following: + +```tsx title="src/modules/products/components/product-actions/index.tsx" badgeLabel="Storefront" badgeColor="blue" +return ( + <> + {/* ... */} + {hasProductBuilder(product) && ( + <> + + + )} + + + {/* ... */} + +) +``` + +You display the `ProductBuilderConfig` component before the price, and you pass the builder configurations to the `ProductPrice` component. + +Finally, find the add-to-cart button and replace it with the following: + +```tsx title="src/modules/products/components/product-actions/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["12"], ["23"], ["24"]]} +return ( + <> + {/* ... */} + + {/* ... */} + +) +``` + +You modify the button's `disabled` prop to also account for the validity of the product builder configuration, and you show the correct button text based on that validity. + +#### Customize MobileActions + +Next, you'll customize the `MobileActions` to show the correct price and button text in mobile view. + +In `src/modules/products/components/product-actions/mobile-actions.tsx`, add the following imports at the top of the file: + +```tsx title="src/modules/products/components/product-actions/mobile-actions.tsx" badgeLabel="Storefront" badgeColor="blue" +import { BuilderConfiguration } from "../../../../types/global" +import { convertToLocale } from "@lib/util/money" +import { hasProductBuilder } from "@lib/util/product-builder" +``` + +Next, add the following props to the `MobileActionsProps` type: + +```tsx title="src/modules/products/components/product-actions/mobile-actions.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["3"], ["4"]]} +type MobileActionsProps = { + // ... + builderConfig?: BuilderConfiguration | null + isBuilderConfigValid?: boolean +} +``` + +The component now accepts the builder configuration and its validity state as props. + +Next, add the props to the destructured parameter of the `MobileActions` component: + +```tsx title="src/modules/products/components/product-actions/mobile-actions.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["3"], ["4"]]} +const MobileActions: React.FC = ({ + // ... + builderConfig, + isBuilderConfigValid = true, +}: MobileActionsProps) => { + // ... +} +``` + +After that, find the `selectedPrice` variable and replace it with the following: + +```tsx title="src/modules/products/components/product-actions/mobile-actions.tsx" badgeLabel="Storefront" badgeColor="blue" +const selectedPrice = useMemo(() => { + if (!price) { + return null + } + const { variantPrice, cheapestPrice } = price + const basePrice = variantPrice || cheapestPrice || null + + if (!basePrice) {return null} + + // Calculate total price including builder configuration + let totalPrice = basePrice.calculated_price_number || 0 + + if (builderConfig) { + // Add complementary products prices + builderConfig.complementary_products.forEach((comp) => { + totalPrice += comp.price || 0 + }) + + // Add addons prices + builderConfig.addons.forEach((addon) => { + const addonPrice = (addon.price || 0) * (addon.quantity || 1) + totalPrice += addonPrice + }) + } + + const currencyCode = basePrice.currency_code || "USD" + + return { + ...basePrice, + calculated_price_number: totalPrice, + calculated_price: convertToLocale({ + amount: totalPrice, + currency_code: currencyCode, + }), + } +}, [price, builderConfig]) +``` + +Similar to the `ProductPrice` component, you set the selected price to the total price calculated from the builder configuration. This ensures that the correct price is displayed in the mobile view as well. + +Finally, in the `return` statement, find the add-to-cart button and replace it with the following: + +```tsx title="src/modules/products/components/product-actions/mobile-actions.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["9"], ["19"], ["20"]]} +return ( + <> + {/* ... */} + + {/* ... */} + +) +``` + +Similar to the `ProductActions` component, you ensure the button's disabled state and text match the builder configuration's validity. + +### Test Product Details Page + +To test out the product details page in the Next.js Starter Storefront: + +1. Start the Medusa application with the following command: + +```bash npm2yarn badgeLabel="Medusa Application" badgeColor="green" +npm run dev +``` + +2. Start the Next.js Starter Storefront with the following command: + +```bash npm2yarn badgeLabel="Storefront" badgeColor="blue" +npm run dev +``` + +3. In the storefront, go to Menu -> Store. +4. Click on the product that has builder configurations. + +You should see the custom fields, complementary products, and addons on the product's page. + +![Product details page with builder configuations](https://res.cloudinary.com/dza7lstvk/image/upload/v1755101580/Medusa%20Resources/CleanShot_2025-08-13_at_19.12.48_2x_tf4goa.png) + +While you can enter custom values and select variants, you still can't add the product variant with its builder configurations to the cart. You'll support that in the next step. + +*** + +## Step 8: Add Product with Builder Configurations to Cart + +In this step, you'll create a workflow that adds products with their builder configurations to the cart, then expose that functionality in an API route that you can send requests to from the storefront. + +### a. Create Workflow + +The workflow will validate the builder configurations, add the main product variant to the cart, then add the complementary and addon products as separate line items. You'll also associate the items with one another using their metadata. + +The workflow will have the following steps: + +- [validateProductBuilderConfigurationStep](#validateProductBuilderConfigurationStep): Validates the product builder configuration +- [addToCartWorkflow](#addToCartWorkflow): Adds the product to the cart. +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Get cart with items details. +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Get updated cart details. + +You only need to implement the `validateProductBuilderConfigurationStep`, as Medusa provides the rest. + +#### validateProductBuilderConfigurationStep + +The `validateProductBuilderConfigurationStep` ensures the chosen builder configurations are valid before proceeding with the cart addition. + +To create the step, create the file `src/workflows/steps/validate-product-builder-configuration.ts` with the following content: + +```ts title="src/workflows/steps/validate-product-builder-configuration.ts" highlights={validateProductBuilderConfigurationStepHighlights1} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { MedusaError } from "@medusajs/framework/utils" + +export type ValidateProductBuilderConfigurationStepInput = { + product_id: string + custom_field_values?: Record + complementary_product_variants?: string[] + addon_variants?: string[] +} + +export const validateProductBuilderConfigurationStep = createStep( + "validate-product-builder-configuration", + async ({ + product_id, + custom_field_values, + complementary_product_variants, + addon_variants, + }: ValidateProductBuilderConfigurationStepInput, { container }) => { + const query = container.resolve("query") + + const { data: [productBuilder] } = await query.graph({ + entity: "product_builder", + fields: [ + "*", + "custom_fields.*", + "complementary_products.*", + "complementary_products.product.variants.*", + "addons.*", + "addons.product.variants.*", + ], + filters: { + product_id, + }, + }) + + if (!productBuilder) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Product builder configuration not found for product ID: ${product_id}` + ) + } + + // TODO validate custom fields, complementary products, and addon products + } +) +``` + +This step receives the product ID and its builder configurations as input. + +In the step, you resolve Query and retrieve the product's builder configurations. If the product builder is not found, you throw an error. + +Next, you need to validate the custom fields to ensure they match the product builder custom fields. Replace the `TODO` with the following: + +```ts title="src/workflows/steps/validate-product-builder-configuration.ts" highlights={validateProductBuilderConfigurationStepHighlights2} +if ( + !productBuilder.custom_fields.length && + custom_field_values && Object.keys(custom_field_values).length > 0 +) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Product doesn't support custom fields.` + ) +} + +for (const field of productBuilder.custom_fields) { + if (!field) { + continue + } + const value = custom_field_values?.[field.name] + + // Check required fields + if (field.is_required && (!value || value === "")) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Custom field "${field.name}" is required` + ) + } + + // Validate field type + if (value !== undefined && value !== "" && field.type === "number" && isNaN(Number(value))) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Custom field "${field.name}" must be a number` + ) + } +} + +// TODO validate complementary products and addon products +``` + +You validate the selected custom fields to ensure that: + +- The product supports custom fields. +- Each required custom field is provided and has a valid value. +- The values of custom fields match their defined types (e.g., numbers are actually numbers). + +Next, you need to validate the complementary products and addon products. Replace the `TODO` with the following: + +```ts title="src/workflows/steps/validate-product-builder-configuration.ts" highlights={validateProductBuilderConfigurationStepHighlights3} +const invalidComplementary = complementary_product_variants?.filter( + (id) => !productBuilder.complementary_products.some((cp) => + cp?.product?.variants.some((variant) => variant.id === id) + ) +) + +if ((invalidComplementary?.length || 0) > 0) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Invalid complementary product variants: ${invalidComplementary!.join(", ")}` + ) +} + +const invalidAddons = addon_variants?.filter( + (id) => !productBuilder.addons.some((addon) => + addon?.product?.variants.some((variant) => variant.id === id) + ) +) + +if ((invalidAddons?.length || 0) > 0) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Invalid addon product variants: ${invalidAddons!.join(", ")}` + ) +} + +return new StepResponse(productBuilder) +``` + +You apply the same validation to complementary and addon products. You make sure the selected product variants exist in the product builder's complementary and addon products. + +Finally, if all configurations are valid, you return the product builder configurations. + +#### Implement Workflow + +You can now implement the workflow that adds products with builder configurations to the cart. + +Create the file `src/workflows/add-product-builder-to-cart.ts` with the following content: + +```ts title="src/workflows/add-product-builder-to-cart.ts" collapsibleLines="1-9" expandButtonLabel="Show Imports" highlights={addToCartWorkflowHighlights} +import { + createWorkflow, + WorkflowResponse, + transform, + when, +} from "@medusajs/framework/workflows-sdk" +import { addToCartWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows" +import { validateProductBuilderConfigurationStep } from "./steps/validate-product-builder-configuration" + +type AddProductBuilderToCartInput = { + cart_id: string + product_id: string + variant_id: string + quantity?: number + custom_field_values?: Record + complementary_product_variants?: string[] // Array of product IDs + addon_variants?: string[] // Array of addon product IDs +} + +export const addProductBuilderToCartWorkflow = createWorkflow( + "add-product-builder-to-cart", + (input: AddProductBuilderToCartInput) => { + // Step 1: Validate the product builder configuration and selections + const productBuilder = validateProductBuilderConfigurationStep({ + product_id: input.product_id, + custom_field_values: input.custom_field_values, + complementary_product_variants: input.complementary_product_variants, + addon_variants: input.addon_variants, + }) + + // TODO add main, complementary, and addon product variants to the cart + } +) +``` + +The workflow accepts the cart, product, variant, and builder configuration information as input. + +So far, you only validate the product builder configuration using the step you created earlier. If the validation fails, the workflow will stop executing. + +Next, you need to add the main product variant to the cart. Replace the `TODO` with the following: + +```ts title="src/workflows/add-product-builder-to-cart.ts" highlights={addProductBuilderToCartWorkflowHighlights2} +// Step 2: Add main product to cart +const addMainProductData = transform({ + input, + productBuilder, +}, (data) => ({ + cart_id: data.input.cart_id, + items: [{ + variant_id: data.input.variant_id, + quantity: data.input.quantity || 1, + metadata: { + product_builder_id: data.productBuilder?.id, + custom_fields: Object.entries(data.input.custom_field_values || {}) + .map(([field_id, value]) => { + const field = data.productBuilder?.custom_fields.find((f) => f?.id === field_id) + return { + field_id, + name: field?.name, + value, + } + }), + is_builder_main_product: true, + }, + }], +})) + +addToCartWorkflow.runAsStep({ + input: addMainProductData, +}) + +// TODO add complementary and addon product variants to cart +``` + +You prepare the data to add the main product variant to the cart. You include in the item's metadata the product builder ID, any custom fields, and a flag to identify it as the main product in the builder configuration. + +After that, you use the [addToCartWorkflow](https://docs.medusajs.com/references/medusa-workflows/addToCartWorkflow/index.html.md) to add the main product to the cart. + +Next, you need to add the complementary and addon product variants to the cart. Replace the `TODO` with the following: + +```ts title="src/workflows/add-product-builder-to-cart.ts" highlights={addProductBuilderToCartWorkflowHighlights3} +// Step 5: Add complementary and addon products +const { + items_to_add: moreItemsToAdd, + main_item_update: mainItemUpdate, +} = transform({ + input, + cartWithMainProduct, +}, (data) => { + if (!data.input.complementary_product_variants?.length && !data.input.addon_variants?.length) { + return {} + } + + // Find the main product line item (most recent addition with builder metadata) + const mainLineItem = data.cartWithMainProduct[0].items.find((item: any) => + item.metadata?.is_builder_main_product === true + ) + + if (!mainLineItem) { + return {} + } + + return { + items_to_add: { + cart_id: data.input.cart_id, + items: [ + ...(data.input.complementary_product_variants?.map((complementaryProductVariant) => ({ + variant_id: complementaryProductVariant, + quantity: 1, + metadata: { + main_product_line_item_id: mainLineItem.id, + }, + })) || []), + ...(data.input.addon_variants?.map((addonVariant) => ({ + variant_id: addonVariant, + quantity: 1, + metadata: { + main_product_line_item_id: mainLineItem.id, + is_addon: true, + }, + })) || []), + ], + }, + main_item_update: { + item_id: mainLineItem.id, + cart_id: data.cartWithMainProduct[0].id, + update: { + metadata: { + cart_line_item_id: mainLineItem.id, + }, + }, + }, + } +}) + +when({ + moreItemsToAdd, + mainItemUpdate, +}, ({ + moreItemsToAdd, + mainItemUpdate, +}) => !!moreItemsToAdd && moreItemsToAdd.items.length > 0 && !!mainItemUpdate) +.then(() => { + addToCartWorkflow.runAsStep({ + input: { + cart_id: moreItemsToAdd!.cart_id, + items: moreItemsToAdd!.items, + }, + }) + // @ts-ignore + .config({ name: "add-more-products-to-cart" }) + + updateLineItemInCartWorkflow.runAsStep({ + input: mainItemUpdate!, + }) +}) + +// TODO retrieve and return updated cart details +``` + +First, you retrieve the cart after adding the main product to get its line items. + +Then, you prepare the data to add the complementary and addon products to the cart. You include the main product line item ID in their metadata to associate them with the main product. Also, you set the `is_addon` flag for addon products. + +You also prepare the data to update the main product line item's metadata with its cart line item ID. This allows you to reference it after the order is placed, since the `metadata` is moved to the order line item's `metadata`. + +Finally, you add the complementary and addon products to the cart using the `addToCartWorkflow`, and update the product's `metadata` with the cart line item ID. + +The last thing you need to do is retrieve the updated cart details after adding all items and return them. Replace the `TODO` with the following: + +```ts title="src/workflows/add-product-builder-to-cart.ts" +// Step 6: Fetch the final updated cart +const { data: updatedCart } = useQueryGraphStep({ + entity: "cart", + fields: ["*", "items.*"], + filters: { + id: input.cart_id, + }, + options: { + throwIfKeyNotFound: true, + }, +}).config({ name: "get-final-cart" }) + +return new WorkflowResponse({ + cart: updatedCart[0], +}) +``` + +You retrieve the final cart details after all items have been added, and you return the updated cart. + +### b. Create API Route + +Next, you'll create the API route that executes the above workflow. + +To create the API route, create the file `src/api/store/carts/[id]/product-builder/route.ts` with the following content: + +```ts title="src/api/store/carts/[id]/product-builder/route.ts" highlights={addToCartApiHighlights} +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { addProductBuilderToCartWorkflow } from "../../../../../workflows/add-product-builder-to-cart" +import { z } from "zod" + +export const AddBuilderProductSchema = z.object({ + product_id: z.string(), + variant_id: z.string(), + quantity: z.number().optional().default(1), + custom_field_values: z.record(z.any()).optional().default({}), + complementary_product_variants: z.array(z.string()).optional().default([]), + addon_variants: z.array(z.string()).optional().default([]), +}) + +export async function POST( + req: MedusaRequest< + z.infer + >, + res: MedusaResponse +) { + + const cartId = req.params.id + + const { result } = await addProductBuilderToCartWorkflow(req.scope).run({ + input: { + cart_id: cartId, + ...req.validatedBody, + }, + }) + + res.json({ + cart: result.cart, + }) +} +``` + +You define a Zod schema to validate the request body, then you expose a `POST` API route at `/store/carts/[id]/product-builder`. + +In the route handler, you execute the `addProductBuilderToCartWorkflow` with the validated request body. You return the updated cart in the response. + +### c. Add Validation Middleware + +To validate the request body before it reaches the API route, you need to add a validation middleware. + +In `src/api/middlewares.ts`, add the following import at the top of the file: + +```ts title="src/api/middlewares.ts" +import { AddBuilderProductSchema } from "./store/carts/[id]/product-builder/route" +``` + +Then, add the following object to the `routes` array in `defineMiddlewares`: + +```ts title="src/api/middlewares.ts" +export default defineMiddlewares({ + routes: [ + // ... + { + matcher: "/store/carts/:id/product-builder", + methods: ["POST"], + middlewares: [ + validateAndTransformBody(AddBuilderProductSchema), + ], + }, + ], +}) +``` + +You apply the `validateAndTransformBody` middleware to the `/store/carts/:id/product-builder` route, passing it the Zod schema you created for validation. + +You'll test out this API route and functionality when you customize the storefront next. + +*** + +## Step 9: Customize Cart in Storefront + +In this step, you'll customize the storefront to: + +- Support adding products with builder configurations to the cart. +- Display addon products with their main product in the cart. +- Display custom field values in the cart. + +### a. Use Add Product Builder to Cart API Route + +You'll start by customizing existing add-to-cart functionality to use the new API route you created in the previous step. + +#### Define Line Item Types + +In `src/types/global.ts`, add the following type definitions useful for your cart customizations: + +```ts title="src/types/global.ts" badgeLabel="Storefront" badgeColor="blue" +export type BuilderLineItemMetadata = { + is_builder_main_product?: boolean + main_product_line_item_id?: string + product_builder_id?: string + custom_fields?: { + field_id: string + name?: string + value: string + }[] + is_addon?: boolean + cart_line_item_id?: string +} + +export type LineItemWithBuilderMetadata = StoreCartLineItem & { + metadata?: BuilderLineItemMetadata +} +``` + +You define the `BuilderLineItemMetadata` type to include all relevant metadata for line items that are part of a product builder configuration, and the `LineItemWithBuilderMetadata` type extends the existing line item type to include this metadata. + +#### Identify Product Builder Items Utility + +Next, you need a utility function to identify whether a line item belongs to a product with builder configurations. + +In `src/lib/util/product-builder.ts`, add the following import at the top of the file: + +```ts title="src/lib/util/product-builder.ts" badgeLabel="Storefront" badgeColor="blue" +import { LineItemWithBuilderMetadata } from "../../types/global" +``` + +Then, add the following function at the end of the file: + +```ts title="src/lib/util/product-builder.ts" badgeLabel="Storefront" badgeColor="blue" +export function isBuilderLineItem(lineItem: LineItemWithBuilderMetadata): boolean { + return lineItem?.metadata?.is_builder_main_product === true +} +``` + +You'll use this function in the next customizations. + +#### Add Builder Product to Cart Function + +In this section, you'll add a server function that sends a request to the API route you created earlier. You'll use this function when adding products with builder configurations to the cart. + +In `src/lib/data/cart.ts`, add the following imports at the top of the file: + +```ts title="src/lib/data/cart.ts" badgeLabel="Storefront" badgeColor="blue" +import { BuilderConfiguration, LineItemWithBuilderMetadata } from "../../types/global" +import { isBuilderLineItem } from "../util/product-builder" +``` + +Then, add the following function to the file: + +```ts title="src/lib/data/cart.ts" badgeLabel="Storefront" badgeColor="blue" +export async function addBuilderProductToCart({ + productId, + variantId, + quantity, + countryCode, + builderConfiguration, +}: { + productId: string + variantId: string + quantity: number + countryCode: string + builderConfiguration?: BuilderConfiguration +}) { + if (!variantId) { + throw new Error("Missing variant ID when adding to cart") + } + + const cart = await getOrSetCart(countryCode) + + if (!cart) { + throw new Error("Error retrieving or creating cart") + } + + // If no builder configuration, use regular addToCart + if (!builderConfiguration) { + return addToCart({ variantId, quantity, countryCode }) + } + + const headers = { + ...(await getAuthHeaders()), + } + + await sdk.client.fetch(`/store/carts/${cart.id}/product-builder`, { + method: "POST", + headers, + body: { + product_id: productId, + variant_id: variantId, + quantity, + custom_field_values: builderConfiguration.custom_fields.reduce( + (acc, field) => { + acc[field.field_id] = field.value + return acc + }, + {} as Record + ), + complementary_product_variants: builderConfiguration.complementary_products.map( + (comp) => comp.variant_id + ), + addon_variants: builderConfiguration.addons.map((addon) => addon.variant_id), + }, + }) + .then(async () => { + const cartCacheTag = await getCacheTag("carts") + revalidateTag(cartCacheTag) + + const fulfillmentCacheTag = await getCacheTag("fulfillment") + revalidateTag(fulfillmentCacheTag) + }) + .catch(medusaError) +} +``` + +This function adds a product with a builder configuration to the cart by sending a request to the API route you created earlier. If no builder configuration is provided, it falls back to the regular `addToCart` function (which is defined in the same file). + +#### Use Add Builder Product to Cart Function + +Finally, you'll use the `addBuilderProductToCart` function in the `ProductActions` component, where the add-to-cart button is located. + +In `src/modules/products/components/product-actions/index.tsx`, add the following imports at the top of the file: + +```tsx title="src/modules/products/components/product-actions/index.tsx" badgeLabel="Storefront" badgeColor="blue" +import { + addBuilderProductToCart, +} from "@lib/data/cart" +import { + toast, +} from "@medusajs/ui" +``` + +Then, in the `ProductActions` component, find the `handleAddToCart` function and replace it with the following: + +```tsx title="src/modules/products/components/product-actions/index.tsx" badgeLabel="Storefront" badgeColor="blue" +const handleAddToCart = async () => { + if (!selectedVariant?.id) {return null} + + setIsAdding(true) + + try { + // Check if product has builder configuration + if (hasProductBuilder(product) && builderConfig) { + await addBuilderProductToCart({ + productId: product.id!, + variantId: selectedVariant.id, + quantity: 1, + countryCode, + builderConfiguration: builderConfig, + }) + } else { + // Use regular addToCart for products without builder configuration + await addToCart({ + variantId: selectedVariant.id, + quantity: 1, + countryCode, + }) + } + } catch (error) { + toast.error(`Failed to add product to cart: ${error}`) + } finally { + setIsAdding(false) + } +} +``` + +If the product has builder configurations, you call the `addBuilderProductToCart` function. Otherwise, you fall back to the regular `addToCart` function. + +#### Test Add to Cart Functionality + +To test out the add-to-cart functionality with builder configurations, make sure that both the Medusa application and the Next.js Starter Storefront are running. + +Then, open the page of a product with builder configurations in the storefront. Select the configurations, and add them to the cart. + +The cart will be updated with the main product and selected complementary and addon product variants. + +![Cart dropdown showing the product with its complementary product](https://res.cloudinary.com/dza7lstvk/image/upload/v1755160166/Medusa%20Resources/CleanShot_2025-08-14_at_11.28.53_2x_bcskgr.png) + +### b. Customize Cart Page + +Next, you'll customize the cart page to display the custom field values of a product, and group addon products with the main product. + +You'll start with some styling changes, then update the cart item rendering logic to include the custom fields and addon products. + +![Screenshot showcasing which areas of the cart page will be updated and how they'll look like](https://res.cloudinary.com/dza7lstvk/image/upload/v1755160329/Medusa%20Resources/CleanShot_2025-08-14_at_11.31.50_2x_tpxlht.png) + +#### Styling Changes + +You'll first update the style of the quantity changer component for a better design. + +![Screenshot showcasing the updated quantity changer component](https://res.cloudinary.com/dza7lstvk/image/upload/v1755160526/Medusa%20Resources/CleanShot_2025-08-14_at_11.35.07_2x_vtmnuz.png) + +In `src/modules/cart/components/cart-item-select/index.tsx`, replace the file content with the following: + +```tsx title="src/modules/cart/components/cart-item-select/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={cartItemSelectHighlights} +"use client" + +import { IconButton, clx } from "@medusajs/ui" +import { + SelectHTMLAttributes, + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react" +import { Minus, Plus } from "@medusajs/icons" + +type NativeSelectProps = { + placeholder?: string + errors?: Record + touched?: Record + max?: number + onQuantityChange?: (quantity: number) => void +} & SelectHTMLAttributes + +const CartItemSelect = forwardRef( + ({ className, children, value: initialValue, onQuantityChange, ...props }, ref) => { + const innerRef = useRef(null) + const [value, setValue] = useState(initialValue as number || 1) + + useImperativeHandle( + ref, + () => innerRef.current + ) + + const onMinus = () => { + setValue((prevValue) => { + return prevValue > 1 ? prevValue - 1 : 1 + }) + } + + const onPlus = () => { + setValue((prevValue) => { + return Math.min(prevValue + 1, props.max || Infinity) + }) + } + + const handleChange = (event: React.ChangeEvent) => { + setValue(Math.min(parseInt(event.target.value) || 1, props.max || Infinity)) + onQuantityChange?.(value) + } + + useEffect(() => { + onQuantityChange?.(value) + }, [value]) + + return ( +
+ + + + + + + +
+ ) + } +) + +CartItemSelect.displayName = "CartItemSelect" + +export default CartItemSelect +``` + +You make the following key changes: + +- Pass the `max` and `onQuantityChange` props to the `CartItemSelect` component. +- Use a `value` state variable to manage the input value. +- Add `+` and `-` buttons to increase or decrease the quantity. +- Call the `onQuantityChange` prop whenever the quantity changes. +- Show an input field rather than a select field for quantity. + +Next, you'll update the styling of the delete button that removes items from the cart. + +![Screenshot showcasing the updated delete button](https://res.cloudinary.com/dza7lstvk/image/upload/v1755160603/Medusa%20Resources/CleanShot_2025-08-14_at_11.36.29_2x_myhwyf.png) + +In `src/modules/common/components/delete-button/index.tsx`, add the following import at the top of the file: + +```tsx title="src/modules/common/components/delete-button/index.tsx" badgeLabel="Storefront" badgeColor="blue" +import { IconButton } from "@medusajs/ui" +``` + +Then, in the `DeleteButton` component, replace the `button` element in the `return` statement with the following: + +```tsx title="src/modules/common/components/delete-button/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["6"], ["7"], ["8"], ["9"]]} +return ( +
+ {/* ... */} + handleDelete(id)}> + {isDeleting ? : } + {children} + +
+) +``` + +Next, you'll update the `LineItemOptions` component to receive a `className` prop that allows customizing its styles. + +In `src/modules/common/components/line-item-options/index.tsx`, add the following import at the top of the file: + +```tsx title="src/modules/common/components/line-item-options/index.tsx" badgeLabel="Storefront" badgeColor="blue" +import { clx } from "@medusajs/ui" +``` + +Next, update the `LineItemOptionsProps` to accept a `className` prop: + +```tsx title="src/modules/common/components/line-item-options/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["3"]]} +type LineItemOptionsProps = { + // ... + className?: string +} +``` + +Then, destructure the `className` prop and use it in the `return` statement of the `LineItemOptions` component: + +```tsx title="src/modules/common/components/line-item-options/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["3"], ["11"]]} +const LineItemOptions = ({ + // ... + className, +}: LineItemOptionsProps) => { + return ( + + Variant: {variant?.title} + + ) +} +``` + +Next, you'll make small adjustments to the text size of the item price components. + +![Screenshot showcasing the updated item price components](https://res.cloudinary.com/dza7lstvk/image/upload/v1755160898/Medusa%20Resources/CleanShot_2025-08-14_at_11.41.23_2x_eitu29.png) + +In `src/modules/common/components/line-item-price/index.tsx`, update the `className` prop of the `span` element containing the price: + +```tsx title="src/modules/common/components/line-item-price/index.tsx" highlights={[["5"]]} badgeLabel="Storefront" badgeColor="blue" +return ( +
+ {/* ... */} + + {convertToLocale({ + amount: currentPrice, + currency_code: currencyCode, + })} + + {/* ... */} +
+) +``` + +You change the `text-base-regular` class to `txt-small`. + +Next, in `src/modules/common/components/line-item-unit-price/index.tsx`, update the `className` prop of the wrapper `div` and the `span` element containing the price: + +```tsx title="src/modules/common/components/line-item-unit-price/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["2"], ["5"]]} +return ( +
+ {/* ... */} + + {convertToLocale({ + amount: total / item.quantity, + currency_code: currencyCode, + })} + + {/* ... */} +
+) +``` + +You changed the `text-ui-fg-muted` class in the wrapper `div` to `text-ui-fg-subtle`, and the `text-base-regular` class in the `span` element to `txt-small`. + +#### Update Item Component + +Next, you'll update the component showing a line item row. This component is used in mutliple places, including the cart and checkout pages. + +You'll update the component to ignore addon products. Instead, you'll show them as part of the main product line item. You'll also display the custom field values of the main product. + +In `src/modules/cart/components/item/index.tsx`, add the following import at the top of the file: + +```tsx title="src/modules/cart/components/item/index.tsx" badgeLabel="Storefront" badgeColor="blue" +import { LineItemWithBuilderMetadata } from "../../../../types/global" +import { isBuilderLineItem } from "../../../../lib/util/product-builder" +``` + +Then, add a `cartItems` prop to the `ItemProps`: + +```tsx title="src/modules/cart/components/item/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["3"]]} +type ItemProps = { + // ... + cartItems?: HttpTypes.StoreCartLineItem[] +} +``` + +And add the prop to the `Item` component's destructured props: + +```tsx title="src/modules/cart/components/item/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["1", "cartItems"]]} +const Item = ({ item, type = "full", currencyCode, cartItems }: ItemProps) => { + // ... +} +``` + +Next, add the following in the component before the `return` statement: + +```tsx title="src/modules/cart/components/item/index.tsx" badgeLabel="Storefront" badgeColor="blue" +const Item = ({ item, type = "full", currencyCode, cartItems }: ItemProps) => { + // ... + // Check if this is a main product builder item + const itemWithMetadata = item as LineItemWithBuilderMetadata + const isMainBuilderProduct = isBuilderLineItem(itemWithMetadata) + + // Find addon items for this main product + const addonItems = isMainBuilderProduct && cartItems + ? cartItems.filter((cartItem: any) => + cartItem.metadata?.main_product_line_item_id === item.id && + cartItem.metadata?.is_addon === true + ) + : [] + + // Don't render addon items as separate rows (they'll be shown under the main item) + if (itemWithMetadata.metadata?.is_addon === true) { + return null + } + + // ... +} +``` + +You create an `itemWithMetadata` variable, which is a typed version of the `item` prop that includes the metadata fields you defined earlier. + +Next, if the item being viewed is a main product with builder configurations, you retrieve its addon items. Otherwise, if it's an addon item, you return `null` to skip rendering it as a separate row. + +Finally, update the `return` statement to the following: + +```tsx title="src/modules/cart/components/item/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={itemComponentHighlights} +return ( + <> + 0 ? "border-b-0": "" + )} data-testid="product-row"> + + + + + + + + + {item.product_title} + + + {!!itemWithMetadata.metadata?.custom_fields && ( +
+ {itemWithMetadata.metadata.custom_fields.map((field) => ( + + {field.name}: {field.value} + + ))} +
+ )} +
+ + {type === "full" && ( + +
+ { + if (value === item.quantity) { + return + } + changeQuantity(value) + }} + data-testid="product-select-button" + max={maxQuantity} + /> + + {updating && } +
+ +
+ )} + + {type === "full" && ( + + + + )} + + + + {type === "preview" && ( + + {item.quantity}x + + + )} + + + +
+ + {/* Display addon items if this is a main builder product */} + {isMainBuilderProduct && addonItems.length > 0 && addonItems.map((addon: any) => ( + + + + +
+
+ + {addon.product_title} + +
+ +
+
+
+
+ + {type === "full" && ( + + + + )} + + {type === "full" && ( + + + + )} + + + + + + +
+ ))} + +) +``` + +You make the following key changes: + +- Render the custom field values. +- Pass the new props to the `CartItemSelect` component. +- Render the add-on items after the main product item. +- Make general styling updates to improve the layout. + +You also need to update the components that use the `Item` component to pass the new `cartItems` prop. + +In `src/modules/cart/templates/items.tsx`, replace the `return` statement with the following: + +```tsx title="src/modules/cart/templates/items.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={itemsTemplateHighlights} +const ItemsTemplate = ({ cart }: ItemsTemplateProps) => { + // ... + return ( +
+
+ Cart +
+ + + + Item + + Quantity + + Price + + + Total + + + + + {items + ? items + .sort((a, b) => { + return (a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1 + }) + .map((item) => { + return ( + + ) + }) + : repeat(5).map((i) => { + return + })} + +
+
+ ) +} +``` + +You pass the `cartItems` prop to the `Item` component, and you pass new class names to other components for better styling. + +Finally, in `src/modules/cart/templates/preview.tsx`, find the `Item` component in the `return` statement and update it to pass the `cartItems` prop: + +```tsx title="src/modules/cart/templates/preview.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["13", "cartItems", "Pass new prop."]]} +const ItemsPreviewTemplate = ({ cart }: ItemsTemplateProps) => { + // ... + return ( +
+ {/* ... */} + + {/* ... */} +
+ ) +} +``` + +#### Test out Changes + +To test out the design changes to the cart page, make sure both the Medusa application and the Next.js Starter Storefront are running. + +Then, open the cart page in the storefront. If you have a product with an addon and custom fields in the cart, you'll see them displayed within the main product's row. + +![Screenshot showcasing the updated cart page with custom fields and addon products](https://res.cloudinary.com/dza7lstvk/image/upload/v1755161965/Medusa%20Resources/CleanShot_2025-08-14_at_11.59.16_2x_pfo20r.png) + +### c. Update Cart Items Count in Dropdown + +The cart dropdown at the top right of the page will display the total number of items in the cart, including addon products. + +You'll update the cart dropdown to ignore the quantity of addon products when displaying the total count. + +In `src/modules/layout/components/cart-dropdown/index.tsx`, add the following variable before the `totalItems` variable in the `CartDropdown` component: + +```tsx title="src/modules/layout/components/cart-dropdown/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["7"]]} +const CartDropdown = ({ + cart: cartState, +}: { + cart?: HttpTypes.StoreCart | null +}) => { + // ... + const filteredItems = cartState?.items?.filter((item) => !item.metadata?.is_addon) + // ... +} +``` + +You filter the items to exclude any that are marked as add-ons. + +Next, replace the `totalItems` declaration with the following: + +```tsx title="src/modules/layout/components/cart-dropdown/index.tsx" badgeLabel="Storefront" badgeColor="blue" +const CartDropdown = ({ + cart: cartState, +}: { + cart?: HttpTypes.StoreCart | null +}) => { + // ... + const totalItems = + filteredItems?.reduce((acc, item) => { + return acc + item.quantity + }, 0) || 0 + // ... +} +``` + +You calculate the total items by summing the quantities of the `filteredItems`, which excludes addon products. + +Finally, in the `return` statement, replace all usages of `cartState.items` with `filteredItems`, and remove the children element of the `DeleteButton` for better styling: + +```tsx title="src/modules/layout/components/cart-dropdown/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={cartDropdownHighlights} +return ( +
+ {/* ... */} + {cartState && filteredItems?.length ? ( + <> +
+ {filteredItems + .sort((a, b) => { + return (a.created_at ?? "") > (b.created_at ?? "") + ? -1 + : 1 + }) + .map((item) => ( + // .. +
+ {/* ... */} + + {/* ... */} +
+ )) + } +
+ {/* ... */} + + ) : ( + {/* ... */} + )} + {/* ... */} +
+) +``` + +#### Test Cart Dropdown Changes + +To test out the cart dropdown changes, make sure both the Medusa application and the Next.js Starter Storefront are running. + +Then, check the "Cart" navigation item at the top right. The total count next to "Cart" should not include the addon products, and the dropdown should exclude them as well. + +![Updated cart dropdown with updated total count and filtered items](https://res.cloudinary.com/dza7lstvk/image/upload/v1755160166/Medusa%20Resources/CleanShot_2025-08-14_at_11.28.53_2x_bcskgr.png) + +*** + +## Step 10: Delete Product with Builder Configurations from Cart + +In this step, you'll implement the logic to delete a product with builder configurations from the cart. This will include removing its addon products from the cart. + +You'll create a workflow, use that workflow in an API route, then customize the storefront to use this API route when deleting a product with builder configurations from the cart. + +### a. Remove Product with Builder Configurations from Cart Workflow + +The workflow to remove a product with builder configurations from the cart has the following steps: + +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve cart details. +- [deleteLineItemsWorkflow](https://docs.medusajs.com/references/medusa-workflows/deleteLineItemsWorkflow/index.html.md): Delete line items from the cart. +- [useQueryGraphStep](https://docs.medusajs.com/references/helper-steps/useQueryGraphStep/index.html.md): Retrieve the updated cart details. + +Medusa provides all of these steps, so you can create the workflow without needing to implement any custom steps. + +Create the file `src/workflows/remove-product-builder-from-cart.ts` with the following content: + +```ts title="src/workflows/remove-product-builder-from-cart.ts" highlights={removeProductBuilderFromCartWorkflowHighlights} +import { + createWorkflow, + WorkflowResponse, + transform, +} from "@medusajs/framework/workflows-sdk" +import { deleteLineItemsWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows" + +type RemoveProductBuilderFromCartInput = { + cart_id: string + line_item_id: string +} + +export const removeProductBuilderFromCartWorkflow = createWorkflow( + "remove-product-builder-from-cart", + (input: RemoveProductBuilderFromCartInput) => { + // Step 1: Get current cart with all items + const { data: carts } = useQueryGraphStep({ + entity: "cart", + fields: ["*", "items.*", "items.metadata"], + filters: { + id: input.cart_id, + }, + options: { + throwIfKeyNotFound: true, + }, + }) + + // Step 2: Remove line item and its addons + const itemsToRemove = transform({ + input, + carts, + }, (data) => { + const cart = data.carts[0] + const targetLineItem = cart.items.find( + (item: any) => item.id === data.input.line_item_id + ) + const lineItemIdsToRemove = [data.input.line_item_id] + const isBuilderItem = + targetLineItem?.metadata?.is_builder_main_product === true + + if (targetLineItem && isBuilderItem) { + // Find all related addon items + const relatedItems = cart.items.filter((item: any) => + item.metadata?.main_product_line_item_id === data.input.line_item_id && + item.metadata?.is_addon === true + ) + + // Add their IDs to the removal list + lineItemIdsToRemove.push( + ...relatedItems.map((item: any) => item.id) + ) + } + + return { + cart_id: data.input.cart_id, + ids: lineItemIdsToRemove, + } + }) + + deleteLineItemsWorkflow.runAsStep({ + input: itemsToRemove, + }) + + // Step 3: Get the updated cart + const { data: updatedCart } = useQueryGraphStep({ + entity: "cart", + fields: ["*", "items.*", "items.metadata"], + filters: { + id: input.cart_id, + }, + options: { + throwIfKeyNotFound: true, + }, + }).config({ name: "get-updated-cart" }) + + return new WorkflowResponse({ + cart: updatedCart[0], + }) + } +) +``` + +This workflow receives the IDs of the cart and the line item to remove. + +In the workflow, you: + +- Retrieve the cart details with its items. +- Prepare the line items to remove by identifying the main product and its related addons. +- Remove the line items from the cart. +- Retrieve the updated cart details. + +You return the cart details in the response. + +### b. Remove Product with Builder Configurations from Cart API Route + +Next, you'll create an API route that uses the workflow you created to remove a product with builder configurations from the cart. + +Create the file `src/api/store/carts/[id]/product-builder/[item_id]/route.ts` with the following content: + +```ts title="src/api/store/carts/[id]/product-builder/[item_id]/route.ts" +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { removeProductBuilderFromCartWorkflow } from "../../../../../../workflows/remove-product-builder-from-cart" + +export const DELETE = async ( + req: MedusaRequest, + res: MedusaResponse +) => { + const { + id: cartId, + item_id: lineItemId, + } = req.params + + const { result } = await removeProductBuilderFromCartWorkflow(req.scope) + .run({ + input: { + cart_id: cartId, + line_item_id: lineItemId, + }, + }) + + res.json({ + cart: result.cart, + }) +} +``` + +You expose a `DELETE` API route at `/store/carts/[id]/product-builder/[item_id]`. + +In the route handler, you execute the `removeProductBuilderFromCartWorkflow` with the cart and line item IDs from the request path parameters. + +You return the cart details in the response. + +### c. Use API Route in Storefront + +Next, you'll customize the storefront to use the API route you created when deleting a product with builder configurations from the cart. + +In `src/lib/data/cart.ts`, add the following function: + +```ts title="src/lib/data/cart.ts" badgeLabel="Storefront" badgeColor="blue" +export async function removeBuilderLineItem(lineItemId: string) { + if (!lineItemId) { + throw new Error("Missing lineItem ID when deleting builder line item") + } + + const cartId = await getCartId() + + if (!cartId) { + throw new Error("Missing cart ID when deleting builder line item") + } + + const headers = { + ...(await getAuthHeaders()), + } + + await sdk.client + .fetch(`/store/carts/${cartId}/product-builder/${lineItemId}`, { + method: "DELETE", + headers, + }) + .then(async () => { + const cartCacheTag = await getCacheTag("carts") + revalidateTag(cartCacheTag) + + const fulfillmentCacheTag = await getCacheTag("fulfillment") + revalidateTag(fulfillmentCacheTag) + }) + .catch(medusaError) +} +``` + +This function sends a `DELETE` request to the API route you created earlier to remove a line item with builder configurations from the cart. + +Next, to use this function when deleting a line item from the cart, you'll update the `DeleteButton` component to accept a new prop that determines whether the item belongs to a product with builder configurations. + +In `src/modules/common/components/delete-button/index.tsx`, add the following import at the top of the file: + +```tsx title="src/modules/common/components/delete-button/index.tsx" badgeLabel="Storefront" badgeColor="blue" +import { removeBuilderLineItem } from "@lib/data/cart" +``` + +Then, pass an `isBuilderConfigItem` prop to the `DeleteButton` component, and update its `handleDelete` function to use it: + +```tsx title="src/modules/common/components/delete-button/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={deleteButtonHighlights} +const DeleteButton = ({ + // ... + isBuilderConfigItem = false, +}: { + // ... + isBuilderConfigItem?: boolean +}) => { + // ... + const handleDelete = async (id: string) => { + setIsDeleting(true) + if (isBuilderConfigItem) { + await removeBuilderLineItem(id).catch((err) => { + setIsDeleting(false) + }) + } else { + await deleteLineItem(id).catch((err) => { + setIsDeleting(false) + }) + } + } + + // ... +} +``` + +You update the `handleDelete` function to use the `removeBuilderLineItem` function if the `isBuilderConfigItem` prop is `true`. Otherwise, it uses the regular `deleteLineItem` function. + +Next, you need to pass the `isBuilderConfigItem` prop to the `DeleteButton` component in the components using it. + +In `src/modules/cart/components/item/index.tsx`, update the first `DeleteButton` component usage in the return statement to pass the `isBuilderConfigItem` prop: + +```tsx title="src/modules/cart/components/item/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["7", "isBuilderConfigItem", "Pass new prop"]]} +return ( + <> + {/* ... */} + + {/* ... */} + +) +``` + +Don't update the `DeleteButton` for addon products, as they don't have builder configurations. + +Then, in `src/modules/layout/components/cart-dropdown/index.tsx`, add the following import at the top of the file: + +```tsx title="src/modules/layout/components/cart-dropdown/index.tsx" badgeLabel="Storefront" badgeColor="blue" +import { LineItemWithBuilderMetadata } from "../../../../types/global" +import { isBuilderLineItem } from "../../../../lib/util/product-builder" +``` + +Next, find the `DeleteButton` usage in the `return` statement and update it to pass the `isBuilderConfigItem` prop: + +```tsx title="src/modules/layout/components/cart-dropdown/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["10", "isBuilderConfigItem", "Pass new prop"]]} +return ( +
+ {/* ... */} + + {/* ... */} +
+) +``` + +### Test Deleting Product with Builder Configurations from Cart + +To test out the changes, make sure both the Medusa application and the Next.js Starter Storefront are running. + +Then, in the storefront, delete the product with builder configurations from the cart either from the cart page or the cart dropdown. The addon item will also be removed from the cart. + +*** + +## Step 11: Show Product Builder Configurations in Order Confirmation + +In this step, you'll customize the order confirmation page in the storefront to group addon products with their main product, similar to the cart page. + +In `src/modules/order/components/item/index.tsx`, add the following import at the top of the file: + +```tsx title="src/modules/order/components/item/index.tsx" badgeLabel="Storefront" badgeColor="blue" +import { clx } from "@medusajs/ui" +import { LineItemWithBuilderMetadata } from "../../../../types/global" +import { isBuilderLineItem } from "../../../../lib/util/product-builder" +``` + +Next, pass a new `orderItems` prop to the `Item` component and its prop type: + +```tsx title="src/modules/order/components/item/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={orderItemHighlights} +type ItemProps = { + // ... + orderItems?: (HttpTypes.StoreCartLineItem | HttpTypes.StoreOrderLineItem)[] +} + +const Item = ({ item, currencyCode, orderItems }: ItemProps) => { + // ... +} +``` + +This prop will include all the items in the order, allowing you to find the addons of a main product. + +After that, add the following in the `Item` component before the `return` statement: + +```tsx title="src/modules/order/components/item/index.tsx" highlights={orderItemLogicHighlights} badgeLabel="Storefront" badgeColor="blue" +const Item = ({ item, currencyCode, orderItems }: ItemProps) => { + // Check if this is a main product builder item + const itemWithMetadata = item as LineItemWithBuilderMetadata + const isMainBuilderProduct = isBuilderLineItem(itemWithMetadata) + + // Find addon items for this main product + const addonItems = isMainBuilderProduct && orderItems + ? orderItems.filter((orderItem: any) => + orderItem.metadata?.main_product_line_item_id === item.metadata?.cart_line_item_id && + orderItem.metadata?.is_addon === true + ) + : [] + + // Don't render addon items as separate rows (they'll be shown under the main item) + if (itemWithMetadata.metadata?.is_addon === true) { + return null + } + + // ... +} +``` + +If the item is a main product, you retrieve its addons. If an item is an addon, you return `null` to skip rendering it as a separate row. + +Finally, replace the `return` statement with the following: + +```tsx title="src/modules/order/components/item/index.tsx" highlights={orderItemReturnsHighlights} badgeLabel="Storefront" badgeColor="blue" +return ( + <> + 0 ? "border-b-0": "" + )} data-testid="product-row"> + +
+ +
+
+ + + + {item.product_title} + + + {!!itemWithMetadata.metadata?.custom_fields && ( +
+ {itemWithMetadata.metadata.custom_fields.map((field) => ( + + {field.name}: {field.value} + + ))} +
+ )} +
+ + + + + + {item.quantity}x{" "} + + + + + + + +
+ + {/* Display addon items if this is a main builder product */} + {isMainBuilderProduct && addonItems.length > 0 && addonItems.map((addon: any) => ( + + + + +
+
+ + {addon.product_title} + +
+ +
+
+
+
+ + + + + + {addon.quantity}x{" "} + + + + + + + +
+ ))} + +) +``` + +You make the following key changes: + +- Show the custom field values of a product with builder configurations. +- Show addons as a row after the main product row. +- Other design and styling changes. + +You need to pass the `orderItems` prop to the `Item` component in the components using it. + +In `src/modules/order/components/items/index.tsx`, find the `Item` component in the return statement and add the `orderItems` prop: + +```tsx title="src/modules/order/components/items/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["10", "orderItems", "Pass new prop"]]} +const Items = ({ order }: ItemsProps) => { + // ... + return ( +
+ {/* ... */} + + {/* ... */} +
+ ) +} +``` + +### Test Order Confirmation Page + +To test out the changes in the order confirmation page, make sure both the Medusa application and the Next.js Starter Storefront are running. + +Then, place an order with a product that has builder configurations. In the confirmation page, you'll see the product with its custom fields and addon items displayed similar to the cart page. + +![Updated order confirmation page](https://res.cloudinary.com/dza7lstvk/image/upload/v1755168706/Medusa%20Resources/CleanShot_2025-08-14_at_13.51.33_2x_bdadlh.png) + +*** + +## Step 12: Show Product Builder Configuration in Order Admin Page + +In the last step, you'll inject an admin widget to the order details page that shows the product builder configurations for each item in the order. + +To create the widget, create the file `src/admin/widgets/order-builder-details-widget.tsx` with the following content: + +```tsx title="src/admin/widgets/order-builder-details-widget.tsx" highlights={orderWidgetHighlights} +import { defineWidgetConfig } from "@medusajs/admin-sdk" +import { Container, Heading, Text, clx } from "@medusajs/ui" +import { DetailWidgetProps, AdminOrder } from "@medusajs/framework/types" + +type BuilderLineItemMetadata = { + is_builder_main_product?: boolean + main_product_line_item_id?: string + product_builder_id?: string + custom_fields?: { + field_id: string + name?: string + value: string + }[] + is_addon?: boolean + cart_line_item_id?: string +} + +type LineItemWithBuilderMetadata = { + id: string + product_title: string + variant_title?: string + quantity: number + metadata?: BuilderLineItemMetadata +} + +const OrderBuilderDetailsWidget = ({ + data: order, +}: DetailWidgetProps) => { + const orderItems = (order.items || []) as LineItemWithBuilderMetadata[] + + // Find all builder main products (items with custom configurations) + const builderItems = orderItems.filter((item) => + item.metadata?.is_builder_main_product || + item.metadata?.custom_fields?.length + ) + + // If no builder items, don't show the widget + if (builderItems.length === 0) { + return null + } + + const getAddonItems = (mainItemId: string) => { + return orderItems.filter((item) => + item.metadata?.main_product_line_item_id === mainItemId && + item.metadata?.is_addon === true + ) + } + + return ( + +
+ Items with Builder Configurations +
+ +
+ {builderItems.map((item, index) => { + const addonItems = getAddonItems(item.metadata?.cart_line_item_id || "") + const isLastItem = index === builderItems.length - 1 + + return ( +
+ {/* Main Product Info */} +
+
+ + {item.product_title} + + {item.variant_title && ( + + Variant: {item.variant_title} + + )} + + Quantity: {item.quantity} + +
+
+ + {/* Custom Fields */} + {item.metadata?.custom_fields && item.metadata.custom_fields.length > 0 && ( +
+ + Custom Fields + +
+ {item.metadata.custom_fields.map((field, index) => ( +
+ + {field.name || `Field ${index + 1}`} + + + {field.value} + +
+ ))} +
+
+ )} + + {/* Addon Products */} + {addonItems.length > 0 && ( +
+ + Add-on Products ({addonItems.length}) + +
+ {addonItems.map((addon) => ( +
+
+ + {addon.product_title} + + {addon.variant_title && ( + + Variant: {addon.variant_title} + + )} + + Quantity: {addon.quantity} + +
+
+ ))} +
+
+ )} +
+ ) + })} +
+
+ ) +} + +export const config = defineWidgetConfig({ + zone: "order.details.side.after", +}) + +export default OrderBuilderDetailsWidget +``` + +You first define types for the line item of a product builder, and a type for its metadata. + +Then, in the widget, you find the items that have builder configurations by checking if they have the `is_builder_main_product` metadata or custom fields. + +If no builder items are found, the widget will not be displayed. Otherwise, you display the item's custom values and add-on products. + +Notice that to find the addons of the main product, you compare the `main_product_line_item_id` of the addon with the `cart_line_item_id` of the main product's item. + +### Test Order Admin Widget + +To test out the widget on the order details page: + +1. Make sure the Medusa Application is running. +2. Open the Medusa Admin dashboard and log in. +3. Go to Orders. +4. Click on an order that contains an item with builder configurations. + +You'll find at the end of the side section an "Items with Builder Configurations" section. The section will show the custom field values and add-ons for each item that has builder configurations. + +![Widget on the order details page showing the builder configurations for each item](https://res.cloudinary.com/dza7lstvk/image/upload/v1755169611/Medusa%20Resources/CleanShot_2025-08-14_at_14.06.40_2x_om7f6e.png) + +*** + +## Next Steps + +You've now implemented the product builder feature in Medusa. You can expand on this feature based on your use case. You can: + +- Allow users to edit their product configurations from the cart or checkout page. +- Disallow purchasing addon products without a main product by filtering products with the `addon` tag. +- Expand on the builder configurations to support more complex setups. + +### Learn More about Medusa + +If you're new to Medusa, check out the [main documentation](https://docs.medusajs.com/docs/learn/index.html.md), where you'll get a more in-depth understanding of all the concepts you've used in this guide and more. + +To learn more about the commerce features that Medusa provides, check out Medusa's [Commerce Modules](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/commerce-modules/index.html.md). + +### Troubleshooting + +If you encounter issues during your development, check out the [troubleshooting guides](https://docs.medusajs.com/Users/shahednasser/medusa/www/apps/resources/app/troubleshooting/index.html.md). + +### Getting Help + +If you encounter issues not covered in the troubleshooting guides: + +1. Visit the [Medusa GitHub repository](https://github.com/medusajs/medusa) to report issues or ask questions. +2. Join the [Medusa Discord community](https://discord.gg/medusajs) for real-time support from community members. + + # Implement Product Reviews in Medusa In this tutorial, you'll learn how to implement product reviews in Medusa. diff --git a/www/apps/resources/app/how-to-tutorials/tutorials/preorder/page.mdx b/www/apps/resources/app/how-to-tutorials/tutorials/preorder/page.mdx index aac9c19a57..46db723a99 100644 --- a/www/apps/resources/app/how-to-tutorials/tutorials/preorder/page.mdx +++ b/www/apps/resources/app/how-to-tutorials/tutorials/preorder/page.mdx @@ -793,7 +793,7 @@ An API route is created in a `route.ts` file under a sub-directory of the `src/a -Refer to the [API routes](!docs!/learn/fundamentals/api-routes) to learn more about them. +Refer to the [API routes](!docs!/learn/fundamentals/api-routes) documentation to learn more about them. diff --git a/www/apps/resources/app/how-to-tutorials/tutorials/product-builder/page.mdx b/www/apps/resources/app/how-to-tutorials/tutorials/product-builder/page.mdx new file mode 100644 index 0000000000..36c2aca7ef --- /dev/null +++ b/www/apps/resources/app/how-to-tutorials/tutorials/product-builder/page.mdx @@ -0,0 +1,6413 @@ +--- +sidebar_label: "Product Builder" +tags: + - name: product + label: "Implement Product Builder" + - server + - tutorial + - name: nextjs + label: "Implement Product Builder" +products: + - product + - cart +--- + +import { Github, PlaySolid, EllipsisHorizontal } from "@medusajs/icons" +import { Prerequisites, WorkflowDiagram, CardList, InlineIcon } from "docs-ui" + +export const metadata = { + title: `Implement Product Builder in Medusa`, +} + +# {metadata.title} + +In this tutorial, you'll learn how to implement a product builder in Medusa. + +When you install a Medusa application, you get a fully-fledged commerce platform with a Framework for customization. The Medusa application's commerce features are built around [Commerce Modules](../../../commerce-modules/page.mdx) that are available out-of-the-box. + +A product builder allows customers to customize the product before adding it to the cart. This may include entering custom options like engraving text, adding complementary products to the cart, or purchasing add-ons with the product, such as insurance. + +## Summary + +By following this tutorial, you will learn how to: + +- Install and set up Medusa with the Next.js Starter Storefront. +- Define and manage data models useful for the product builder. +- Allow admin users to manage the builder configurations of a product from Medusa Admin. +- Customize the storefront to allow customers to choose a product's builder configurations. +- Customize cart and order pages on the storefront to reflect the selected builder configurations of items. +- Customize the order details page on the Medusa Admin to reflect the selected builder configurations of items. + +You can follow this tutorial whether you're new to Medusa or an advanced Medusa developer. + +![Screenshot of how the product builder will look like in the storefront](https://res.cloudinary.com/dza7lstvk/image/upload/v1755101580/Medusa%20Resources/CleanShot_2025-08-13_at_19.12.48_2x_tf4goa.png) + + + +--- + +## Step 1: Install a Medusa Application + + + +Start by installing the Medusa application on your machine with the following command: + +```bash +npx create-medusa-app@latest +``` + +You'll first be asked for the project's name. Then, when asked whether you want to install the [Next.js Starter Storefront](../../../nextjs-starter/page.mdx), choose Yes. + +Afterward, the installation process will start, which will install the Medusa application in a directory with your project's name, and the Next.js Starter Storefront in a separate directory with the `{project-name}-storefront` name. + + + +The Medusa application is composed of a headless Node.js server and an admin dashboard. The storefront is installed or custom-built separately and connects to the Medusa application through its REST endpoints, called [API routes](!docs!/learn/fundamentals/api-routes). Learn more in [Medusa's Architecture documentation](!docs!/learn/introduction/architecture). + + + +Once the installation finishes successfully, the Medusa Admin dashboard will open with a form to create a new user. Enter the user's credentials and submit the form. Afterward, you can log in with the new user and explore the dashboard. + + + +Check out the [troubleshooting guides](../../../troubleshooting/create-medusa-app-errors/page.mdx) for help. + + + +--- + +## Step 2: Create Product Builder Module + +In Medusa, you can build custom features in a [module](!docs!/learn/fundamentals/modules). A module is a reusable package with the data models and functionalities related to a single feature or domain. Medusa integrates the module into your application without implications or side effects on your setup. + + + +Refer to the [Modules](!docs!/learn/fundamentals/modules) documentation to learn more. + + + +In this step, you'll build a Product Builder Module that defines the data models and logic to manage product builder configurations. The module will support three types of configurations: + +1. **Custom Fields**: Allow customers to enter personalized information like engraving text or custom messages for the product. +2. **Complementary Products**: Suggest related products that enhance the main product, like keyboards with computers. +3. **Add-ons**: Optional extras like warranties, insurance, or premium features that customers can purchase alongside the main product. + +### a. Create Module Directory + +Create the directory `src/modules/product-builder` that will hold the Product Builder Module's code. + +### b. Create Data Models + +A data model represents a table in the database. You create data models using Medusa's Data Model Language (DML). It simplifies defining a table's columns, relations, and indexes with straightforward methods and configurations. + + + +Refer to the [Data Models](!docs!/learn/fundamentals/modules#1-create-data-model) documentation to learn more. + + + +For the Product Builder Module, you'll define four data models to represent the different aspects of product customization. + +#### ProductBuilder Data Model + +The first data model will hold the main builder configurations for a product. It will have relations to the custom fields, complementary products, and add-ons. + +To create the data model, create the file `src/modules/product-builder/models/product-builder.ts` with the following content: + +export const productBuilderHighlights = [ + ["7", "id", "The product builder configuration's ID."], + ["8", "product_id", "The ID of the associated product."], + ["9", "custom_fields", "The custom fields for this product."], + ["12", "complementary_products", "The complementary products for this product."], + ["15", "addons", "The add-on products for this product."] +] + +```ts title="src/modules/product-builder/models/product-builder.ts" highlights={productBuilderHighlights} +import { model } from "@medusajs/framework/utils" +import ProductBuilderCustomField from "./product-builder-custom-field" +import ProductBuilderComplementary from "./product-builder-complementary" +import ProductBuilderAddon from "./product-builder-addon" + +const ProductBuilder = model.define("product_builder", { + id: model.id().primaryKey(), + product_id: model.text().unique(), + custom_fields: model.hasMany(() => ProductBuilderCustomField, { + mappedBy: "product_builder", + }), + complementary_products: model.hasMany(() => ProductBuilderComplementary, { + mappedBy: "product_builder", + }), + addons: model.hasMany(() => ProductBuilderAddon, { + mappedBy: "product_builder", + }), +}) + +export default ProductBuilder +``` + +The `ProductBuilder` data model has the following properties: + +- `id`: The primary key of the table. +- `product_id`: The ID of the product that this builder configuration applies to. + - Later, you'll learn how to link this data model to Medusa's `Product` data model. +- `custom_fields`: Fields that the customer can personalize. +- `complementary_products`: Products to suggest alongside the main product. +- `addons`: Products that the customer can buy alongside the main product. + +Ignore the type errors for the related data models. You'll create them next. + + + +Learn more about defining data model properties in the [Property Types documentation](!docs!/learn/fundamentals/data-models/properties). + + + +#### ProductBuilderCustomField Data Model + +The `ProductBuilderCustomField` data model represents fields that the customer can personalize. For example, engraving text or custom messages. + +To create the data model, create the file `src/modules/product-builder/models/product-builder-custom-field.ts` with the following content: + +export const customFieldHighlights = [ + ["5", "id", "The custom field's ID."], + ["6", "name", "The display name of the custom field."], + ["7", "type", "The input type (text or number)."], + ["8", "description", "Optional description to help customers understand the field."], + ["9", "is_required", "Whether this field is required for the product."], + ["10", "product_builder", "The parent product builder configuration."] +] + +```ts title="src/modules/product-builder/models/product-builder-custom-field.ts" highlights={customFieldHighlights} +import { model } from "@medusajs/framework/utils" +import ProductBuilder from "./product-builder" + +const ProductBuilderCustomField = model.define("product_builder_custom_field", { + id: model.id().primaryKey(), + name: model.text(), + type: model.text(), + description: model.text().nullable(), + is_required: model.boolean().default(false), + product_builder: model.belongsTo(() => ProductBuilder, { + mappedBy: "custom_fields", + }), +}) + +export default ProductBuilderCustomField +``` + +The `ProductBuilderCustomField` data model has the following properties: + +- `id`: The primary key of the table. +- `name`: The display name shown to customers (for example, "Engraving Text" or "Custom Message"). +- `type`: The input type, such as `text` or `number`. +- `description`: Optional helper text to guide customers (for example, "Enter your name to be engraved"). +- `is_required`: Whether customers must fill this field before adding the product to cart. +- `product_builder`: A relation back to the parent `ProductBuilder` configuration. + +#### ProductBuilderComplementary Data Model + +The `ProductBuilderComplementary` data model represents products that are suggested alongside the main product. For example, if you're selling an iPad, you can suggest a keyboard to be purchased together. + +To create the data model, create the file `src/modules/product-builder/models/product-builder-complementary.ts` with the following content: + +export const complementaryHighlights = [ + ["5", "id", "The complementary product link's ID."], + ["6", "product_id", "The ID of the complementary product."], + ["7", "product_builder", "The parent product builder configuration."] +] + +```ts title="src/modules/product-builder/models/product-builder-complementary.ts" highlights={complementaryHighlights} +import { model } from "@medusajs/framework/utils" +import ProductBuilder from "./product-builder" + +const ProductBuilderComplementary = model.define("product_builder_complementary", { + id: model.id().primaryKey(), + product_id: model.text(), + product_builder: model.belongsTo(() => ProductBuilder, { + mappedBy: "complementary_products", + }), +}) + +export default ProductBuilderComplementary +``` + +The `ProductBuilderComplementary` data model has the following properties: + +- `id`: The primary key of the table. +- `product_id`: The ID of the complementary product to suggest. + - Later, you'll learn how to link this to Medusa's `Product` data model. +- `product_builder`: A relation back to the parent `ProductBuilder` configuration. + +#### ProductBuilderAddon Data Model + +The last data model you'll implement is the `ProductBuilderAddon` data model, which represents optional add-on products like warranties or premium features. Add-ons are typically only sold with the main product. + +To create the data model, create the file `src/modules/product-builder/models/product-builder-addon.ts` with the following content: + +export const addonHighlights = [ + ["5", "id", "The add-on's ID."], + ["6", "product_id", "The ID of the add-on product."], + ["7", "product_builder", "The parent product builder configuration."] +] + +```ts title="src/modules/product-builder/models/product-builder-addon.ts" highlights={addonHighlights} +import { model } from "@medusajs/framework/utils" +import ProductBuilder from "./product-builder" + +const ProductBuilderAddon = model.define("product_builder_addon", { + id: model.id().primaryKey(), + product_id: model.text(), + product_builder: model.belongsTo(() => ProductBuilder, { + mappedBy: "addons", + }), +}) + +export default ProductBuilderAddon +``` + +The `ProductBuilderAddon` data model has the following properties: + +- `id`: The primary key of the table. +- `product_id`: The ID of the add-on product (for example, warranty or insurance product). + - Later, you'll learn how to link this to Medusa's `Product` data model. +- `product_builder`: A relation back to the parent `ProductBuilder` configuration. + +### c. Create Module's Service + +You can manage your module's data models in a service. + +A service is a TypeScript class that the module exports. In the service's methods, you can connect to the database, allowing you to manage your data models, or connect to a third-party service, which is useful if you're integrating with external services. + + + +Refer to the [Module Service documentation](!docs!/learn/fundamentals/modules#2-create-service) to learn more. + + + +To create the Product Builder Module's service, create the file `src/modules/product-builder/service.ts` with the following content: + +```ts title="src/modules/product-builder/service.ts" +import { MedusaService } from "@medusajs/framework/utils" +import ProductBuilder from "./models/product-builder" +import ProductBuilderCustomField from "./models/product-builder-custom-field" +import ProductBuilderComplementary from "./models/product-builder-complementary" +import ProductBuilderAddon from "./models/product-builder-addon" + +class ProductBuilderModuleService extends MedusaService({ + ProductBuilder, + ProductBuilderCustomField, + ProductBuilderComplementary, + ProductBuilderAddon, +}) {} + +export default ProductBuilderModuleService +``` + +The `ProductBuilderModuleService` extends `MedusaService`, which generates a class with data-management methods for your module's data models. This saves you time on implementing Create, Read, Update, and Delete (CRUD) methods. + +So, the `ProductBuilderModuleService` class now has methods like `createProductBuilders` and `retrieveProductBuilder`. + + + +Find all methods generated by the `MedusaService` in [the Service Factory](../../../service-factory-reference/page.mdx) reference. + + + +### d. Create the Module Definition + +The final piece to a module is its definition, which you export in an `index.ts` file at its root directory. This definition tells Medusa the name of the module and its service. + +So, create the file `src/modules/product-builder/index.ts` with the following content: + +export const moduleHighlights = [ + ["4", "PRODUCT_BUILDER_MODULE", "The module's unique name identifier."], + ["6", "Module", "Function to create a module definition."], + ["7", "service", "Register the service with the module."] +] + +```ts title="src/modules/product-builder/index.ts" highlights={moduleHighlights} +import Service from "./service" +import { Module } from "@medusajs/framework/utils" + +export const PRODUCT_BUILDER_MODULE = "productBuilder" + +export default Module(PRODUCT_BUILDER_MODULE, { + service: Service, +}) +``` + +You use the `Module` function to create the module's definition. It accepts two parameters: + +1. The module's name, which is `productBuilder`. +2. An object with a required property `service` indicating the module's service. + +You also export the module's name as `PRODUCT_BUILDER_MODULE` so you can reference it later. + +### Add Module to Medusa's Configurations + +Once you finish building the module, add it to Medusa's configurations to start using it. + +In `medusa-config.ts`, add a `modules` property and pass an array with your custom module: + +```ts title="medusa-config.ts" +module.exports = defineConfig({ + // ... + modules: [ + { + resolve: "./src/modules/product-builder", + }, + ], +}) +``` + +Each object in the `modules` array has a `resolve` property, whose value is either a path to the module's directory, or an `npm` package’s name. + +### Generate Migrations + +Since data models represent tables in the database, you define how they're created in the database with migrations. A migration is a TypeScript class that defines database changes made by a module. + + + +Refer to the [Migrations documentation](!docs!/learn/fundamentals/modules#5-generate-migrations) to learn more. + + + +Medusa's CLI tool can generate the migrations for you. To generate a migration for the Product Builder Module, run the following command in your Medusa application's directory: + +```bash +npx medusa db:generate productBuilder +``` + +The `db:generate` command of the Medusa CLI accepts the name of the module to generate the migration for. You'll now have a `migrations` directory under `src/modules/product-builder` that holds the generated migration. + +Then, to reflect these migrations on the database, run the following command: + +```bash +npx medusa db:migrate +``` + +The tables for the data models are now created in the database. + +--- + +## Step 3: Define Links between Data Models + +Since Medusa isolates modules to integrate them into your application without side effects, you can't directly create relationships between data models of different modules. + +Instead, Medusa provides a mechanism to define links between data models, and retrieve and manage linked records while maintaining module isolation. + + + +Refer to the [Module Links](!docs!/learn/fundamentals/module-links) documentation to learn more about defining links. + + + +In this step, you'll define links between the data models in the Product Builder Module and the data models in Medusa's Product Module: + +- `ProductBuilder` ↔ `Product`: A product builder record represents the builder configurations of a product. +- `ProductBuilderComplementary` ↔ `Product`: A complementary product record suggests a Medusa product related to the main product. +- `ProductBuilderAddon` ↔ `Product`: An add-on product record suggests a Medusa product that can be added to the main product in the cart. + +### a. ProductBuilder ↔ Product + +To define a link between the `ProductBuilder` and `Product` data models, create the file `src/links/product-builder-product.ts` with the following content: + +export const productBuilderLinkHighlights = [ + ["7", "linkable", "Link configuration for `ProductBuilder`."], + ["8", "deleteCascade", "Delete builder when product is deleted."], + ["10", "ProductModule.linkable.product", "Link to Medusa's Product data model."] +] + +```ts title="src/links/product-builder-product.ts" highlights={productBuilderLinkHighlights} +import ProductBuilderModule from "../modules/product-builder" +import ProductModule from "@medusajs/medusa/product" +import { defineLink } from "@medusajs/framework/utils" + +export default defineLink( + { + linkable: ProductBuilderModule.linkable.productBuilder, + deleteCascade: true, + }, + ProductModule.linkable.product +) +``` + +You define a link using the `defineLink` function. It accepts two parameters: + +1. An object indicating the first data model part of the link. A module has a special `linkable` property that contains link configurations for its data models. You pass the linkable configurations of the Product Builder Module's `ProductBuilder` data model, and you enable the `deleteCascade` option to automatically delete the builder configuration when the product is deleted. +2. An object indicating the second data model part of the link. You pass the linkable configurations of the Product Module's `Product` data model. + +### b. ProductBuilderComplementary ↔ Product + +Next, to define a link between the `ProductBuilderComplementary` and `Product` data models, create the file `src/links/product-builder-complementary-product.ts` with the following content: + +export const complementaryLinkHighlights = [ + ["7", "linkable", "Link configuration for `ProductBuilderComplementary`."], + ["8", "deleteCascade", "Delete when main product is deleted."] +] + +```ts title="src/links/product-builder-complementary-product.ts" highlights={complementaryLinkHighlights} +import ProductBuilderModule from "../modules/product-builder" +import ProductModule from "@medusajs/medusa/product" +import { defineLink } from "@medusajs/framework/utils" + +export default defineLink( + { + linkable: ProductBuilderModule.linkable.productBuilderComplementary, + deleteCascade: true, + }, + ProductModule.linkable.product +) +``` + +You define a link similarly to the previous one. You also enable the `deleteCascade` option to automatically delete the complementary product record when the main product is deleted. + +### c. ProductBuilderAddon ↔ Product + +Finally, to define a link between the `ProductBuilderAddon` and `Product` data models, create the file `src/links/product-builder-addon-product.ts` with the following content: + +export const addonLinkHighlights = [ + ["7", "linkable", "Link configuration for `ProductBuilderAddon`."], + ["8", "deleteCascade", "Delete when main product is deleted."] +] + +```ts title="src/links/product-builder-addon-product.ts" highlights={addonLinkHighlights} +import ProductBuilderModule from "../modules/product-builder" +import ProductModule from "@medusajs/medusa/product" +import { defineLink } from "@medusajs/framework/utils" + +export default defineLink( + { + linkable: ProductBuilderModule.linkable.productBuilderAddon, + deleteCascade: true, + }, + ProductModule.linkable.product +) +``` + +Similarly to the previous links, you define a link between the `ProductBuilderAddon` and `Product` data models. You also enable the `deleteCascade` option to automatically delete the add-on product record when the main product is deleted. + +### d. Sync Links to Database + +After defining links, you need to sync them to the database. This creates the necessary tables to store the links. + +To sync the links to the database, run the migrations command again in the Medusa application's directory: + +```bash +npx medusa db:migrate +``` + +This command will create the necessary tables to store the links between your Product Builder Module and Medusa's Product Module. + +--- + +## Step 4: Manage Product Builder Configurations + +In this step, you'll implement the logic to manage product builder configurations. You'll also expose this functionality to clients, allowing you later to use it in the admin dashboard customizations. + +To implement the product builder management functionality, you'll create: + +- A [workflow](!docs!/learn/fundamentals/workflows) to create or update (upsert) product builder configurations. +- An [API route](!docs!/learn/fundamentals/api-routes) to expose the workflow's functionality to client applications. + +### a. Upsert Product Builder Workflow + +The first workflow you'll implement creates or updates builder configurations for a product. + +A workflow is a series of queries and actions, called steps, that complete a task. A workflow is similar to a function, but it allows you to track its executions' progress, define roll-back logic, and configure other advanced features. + + + +Refer to the [Workflows](!docs!/learn/fundamentals/workflows) documentation to learn more. + + + +The workflow you'll build will have the following steps: + + + +The `useQueryGraphStep`, `createRemoteLinkStep`, and `dismissRemoteLinkStep` are available through Medusa's `@medusajs/medusa/core-flows` package. You'll implement other steps in the workflow. + +#### createProductBuilderStep + +The `createProductBuilderStep` creates a new product builder configuration. + +To create the step, create the file `src/workflows/steps/create-product-builder.ts` with the following content: + +export const createProductBuilderStepHighlights = [ + ["11", "productBuilderModuleService", "Resolve the module service from container."], + ["13", "productBuilder", "Create the product builder record."], + ["24", "deleteProductBuilders", "Delete the product builder if an error occurs."] +] + +```ts title="src/workflows/steps/create-product-builder.ts" highlights={createProductBuilderStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { PRODUCT_BUILDER_MODULE } from "../../modules/product-builder" + +export type CreateProductBuilderStepInput = { + product_id: string +} + +export const createProductBuilderStep = createStep( + "create-product-builder", + async (input: CreateProductBuilderStepInput, { container }) => { + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + + const productBuilder = await productBuilderModuleService.createProductBuilders({ + product_id: input.product_id, + }) + + return new StepResponse(productBuilder, productBuilder) + }, + async (productBuilder, { container }) => { + if (!productBuilder) {return} + + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + + await productBuilderModuleService.deleteProductBuilders(productBuilder.id) + } +) +``` + +You create a step with the `createStep` function. It accepts three parameters: + +1. The step's unique name. +2. An async function that receives two parameters: + - The step's input, which is an object with the product builder's properties. + - An object that has properties including the [Medusa container](!docs!/learn/fundamentals/medusa-container), which is a registry of Framework and commerce tools that you can access in the step. +3. An async compensation function that undoes the actions performed by the step function. This function is only executed if an error occurs during the workflow's execution. + +In the step function, you resolve the Product Builder Module's service from the Medusa container and create a product builder record. + +A step function must return a `StepResponse` instance with the step's output, which is the created product builder record in this case. + +You also pass the product builder record to the compensation function, which deletes the product builder record if an error occurs during the workflow's execution. + +#### prepareProductBuilderCustomFieldsStep + +The `prepareProductBuilderCustomFieldsStep` receives the custom fields from the workflow's input and returns which custom fields should be created, updated, or deleted. + +To create the step, create the file `src/workflows/upsert-product-builder.ts` with the following content: + +export const prepareCustomFieldsStepHighlights = [ + ["21", "listProductBuilderCustomFields", "Get existing custom fields."], + ["26", "toCreate", "Custom fields to create."], + ["27", "toUpdate", "Custom fields to update."] +] + +```ts title="src/workflows/upsert-product-builder.ts" highlights={prepareCustomFieldsStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { PRODUCT_BUILDER_MODULE } from "../../modules/product-builder" + +export type PrepareProductBuilderCustomFieldsStepInput = { + product_builder_id: string + custom_fields?: Array<{ + id?: string + name: string + type: string + is_required?: boolean + description?: string | null + }> +} + +export const prepareProductBuilderCustomFieldsStep = createStep( + "prepare-product-builder-custom-fields", + async (input: PrepareProductBuilderCustomFieldsStepInput, { container }) => { + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + + // Get existing custom fields for this product builder + const existingCustomFields = await productBuilderModuleService.listProductBuilderCustomFields({ + product_builder_id: input.product_builder_id, + }) + + // Separate operations: create, update, and delete + const toCreate: any[] = [] + const toUpdate: any[] = [] + + // TODO determine the fields to create, update, or delete + } +) +``` + +The step function receives as an input the product builder ID and the custom fields to manage. + +In the step, you resolve the Product Builder Module's service and retrieve the existing custom fields associated with the product builder. + +Then, you prepare arrays to hold the fields to create and update. + +Next, you need to check which custom fields should be created, updated, or deleted based on the input and existing custom fields. + +Replace the `TODO` with the following: + +export const prepareCustomFieldsStepHighlights2 = [ + ["6", "toUpdate", "Add the custom field to this array if it exists."], + ["15", "toCreate", "Add the custom field to this array if it does not exist."], + ["26", "toDelete", "Add the custom field to this array if it exists but isn't in the input."] +] + +```ts title="src/workflows/upsert-product-builder.ts" highlights={prepareCustomFieldsStepHighlights2} +// Process input fields to determine creates vs updates +input.custom_fields?.forEach((fieldData) => { + const existingField = existingCustomFields.find((f) => f.id === fieldData.id) + if (fieldData.id && existingField) { + // Update existing field + toUpdate.push({ + id: fieldData.id, + name: fieldData.name, + type: fieldData.type, + is_required: fieldData.is_required ?? false, + description: fieldData.description ?? "", + }) + } else { + // Create new field + toCreate.push({ + product_builder_id: input.product_builder_id, + name: fieldData.name, + type: fieldData.type, + is_required: fieldData.is_required ?? false, + description: fieldData.description ?? "", + }) + } +}) + +// Find fields to delete (existing but not in input) +const toDelete = existingCustomFields.filter( + (field) => !input.custom_fields?.some((f) => f.id === field.id) +) + +return new StepResponse({ + toCreate, + toUpdate, + toDelete, +}) +``` + +You loop over the `custom_fields` array in the input to determine which fields need to be created or updated, then you add them to the appropriate arrays. + +Afterwards, you find the fields that need to be deleted by checking which existing fields are not present in the input. + +Finally, you return an object that has the custom fields to create, update, and delete. + +#### createProductBuilderCustomFieldsStep + +The `createProductBuilderCustomFieldsStep` creates custom fields. + +To create the step, create the file `src/workflows/steps/create-product-builder-custom-fields.ts` with the following content: + +export const createCustomFieldsStepHighlights = [ + ["19", "createdFields", "Create new custom fields."], + ["33", "deleteProductBuilderCustomFields", "Rollback: delete created fields."] +] + +```ts title="src/workflows/steps/create-product-builder-custom-fields.ts" highlights={createCustomFieldsStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { PRODUCT_BUILDER_MODULE } from "../../modules/product-builder" + +export type CreateProductBuilderCustomFieldsStepInput = { + custom_fields: Array<{ + product_builder_id: string + name: string + type: string + is_required: boolean + description?: string + }> +} + +export const createProductBuilderCustomFieldsStep = createStep( + "create-product-builder-custom-fields", + async (input: CreateProductBuilderCustomFieldsStepInput, { container }) => { + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + + const createdFields = await productBuilderModuleService.createProductBuilderCustomFields( + input.custom_fields + ) + + return new StepResponse(createdFields, { + createdItems: createdFields, + }) + }, + async (compensationData, { container }) => { + if (!compensationData?.createdItems?.length) { + return + } + + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + await productBuilderModuleService.deleteProductBuilderCustomFields( + compensationData.createdItems.map((f: any) => f.id) + ) + } +) +``` + +This step receives the custom fields to create as input. + +In the step function, you create the custom fields and return them. + +In the compensation function, you delete the created custom fields if an error occurs during the workflow's execution. + +#### updateProductBuilderCustomFieldsStep + +The `updateProductBuilderCustomFieldsStep` updates existing custom fields. + +To create the step, create the file `src/workflows/steps/update-product-builder-custom-fields.ts` with the following content: + +export const updateProductBuilderCustomFieldsStepHighlights = [ + ["20", "originalFields", "Store the original fields for compensation."], + ["24", "updatedFields", "Update the custom fields."], + ["38", "updateProductBuilderCustomFields", "Restore original custom fields if an error occurs."] +] + +```ts title="src/workflows/steps/update-product-builder-custom-fields.ts" highlights={updateProductBuilderCustomFieldsStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { PRODUCT_BUILDER_MODULE } from "../../modules/product-builder" + +export type UpdateProductBuilderCustomFieldsStepInput = { + custom_fields: Array<{ + id: string + name: string + type: string + is_required: boolean + description?: string + }> +} + +export const updateProductBuilderCustomFieldsStep = createStep( + "update-product-builder-custom-fields", + async (input: UpdateProductBuilderCustomFieldsStepInput, { container }) => { + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + + // Store original state for compensation + const originalFields = await productBuilderModuleService.listProductBuilderCustomFields({ + id: input.custom_fields.map((f) => f.id), + }) + + const updatedFields = await productBuilderModuleService.updateProductBuilderCustomFields( + input.custom_fields + ) + + return new StepResponse(updatedFields, { + originalItems: originalFields, + }) + }, + async (compensationData, { container }) => { + if (!compensationData?.originalItems?.length) { + return + } + + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + await productBuilderModuleService.updateProductBuilderCustomFields( + compensationData.originalItems.map((f: any) => ({ + id: f.id, + name: f.name, + type: f.type, + is_required: f.is_required, + description: f.description, + })) + ) + } +) +``` + +The step receives the custom fields to update as input. + +In the step function, you update the custom fields and return them. + +In the compensation function, you restore the custom fields to their original values if an error occurs during the workflow's execution. + +#### deleteProductBuilderCustomFieldsStep + +The `deleteProductBuilderCustomFieldsStep` deletes custom fields. + +To create the step, create the file `src/workflows/steps/delete-product-builder-custom-fields.ts` with the following content: + +export const deleteProductBuilderCustomFieldsStepHighlights = [ + ["20", "deleteProductBuilderCustomFields", "Delete the custom fields."], + ["34", "createProductBuilderCustomFields", "Restore the deleted custom fields if an error occurs."] +] + +```ts title="src/workflows/steps/delete-product-builder-custom-fields.ts" highlights={deleteProductBuilderCustomFieldsStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { PRODUCT_BUILDER_MODULE } from "../../modules/product-builder" + +export type DeleteProductBuilderCustomFieldsStepInput = { + custom_fields: Array<{ + id: string + product_builder_id: string + name: string + type: string + is_required: boolean + description?: string | null + }> +} + +export const deleteProductBuilderCustomFieldsStep = createStep( + "delete-product-builder-custom-fields", + async (input: DeleteProductBuilderCustomFieldsStepInput, { container }) => { + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + + await productBuilderModuleService.deleteProductBuilderCustomFields( + input.custom_fields.map((f) => f.id) + ) + + return new StepResponse(input.custom_fields, { + deletedItems: input.custom_fields, + }) + }, + async (compensationData, { container }) => { + if (!compensationData?.deletedItems?.length) { + return + } + + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + await productBuilderModuleService.createProductBuilderCustomFields( + compensationData.deletedItems.map((f: any) => ({ + id: f.id, + product_builder_id: f.product_builder_id, + name: f.name, + type: f.type, + is_required: f.is_required, + description: f.description, + })) + ) + } +) +``` + +The step receives the custom fields to delete as input. + +In the step function, you delete the custom fields and return them. + +In the compensation function, you restore the custom fields if an error occurs during the workflow's execution. + +#### prepareProductBuilderComplementaryProductsStep + +The `prepareProductBuilderComplementaryProductsStep` receives the complementary products from the workflow's input and returns which complementary products should be created or deleted. + +To create the step, create the file `src/workflows/steps/prepare-product-builder-complementary-products.ts` with the following content: + +export const prepareProductBuilderComplementaryProductsStepHighlights = [ + ["18", "existingComplementaryProducts", "Get existing complementary products."], + ["33", "toCreate", "Add new complementary products to create."], + ["41", "toDelete", "Add existing complementary products to delete."] +] + +```ts title="src/workflows/steps/prepare-product-builder-complementary-products.ts" highlights={prepareProductBuilderComplementaryProductsStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { PRODUCT_BUILDER_MODULE } from "../../modules/product-builder" + +export type PrepareProductBuilderComplementaryProductsStepInput = { + product_builder_id: string + complementary_products?: Array<{ + id?: string + product_id: string + }> +} + +export const prepareProductBuilderComplementaryProductsStep = createStep( + "prepare-product-builder-complementary-products", + async (input: PrepareProductBuilderComplementaryProductsStepInput, { container }) => { + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + + // Get existing complementary products for this product builder + const existingComplementaryProducts = await productBuilderModuleService + .listProductBuilderComplementaries({ + product_builder_id: input.product_builder_id, + }) + + // Separate operations: create and delete + const toCreate: any[] = [] + + // Process input products to determine creates + input.complementary_products?.forEach((productData) => { + const existingProduct = existingComplementaryProducts.find( + (p) => p.product_id === productData.product_id + ) + if (!existingProduct) { + // Create new complementary product + toCreate.push({ + product_builder_id: input.product_builder_id, + product_id: productData.product_id, + }) + } + }) + + // Find products to delete (existing but not in input) + const toDelete = existingComplementaryProducts.filter( + (product) => !input.complementary_products?.some( + (p) => p.product_id === product.product_id + ) + ) + + return new StepResponse({ + toCreate, + toDelete, + }) + } +) +``` + +The step receives the ID of the product builder and the complementary products to manage as input. + +In the step, you retrieve the existing complementary products for the specified product builder and determine which products need to be created or deleted based on whether it exists in the input. + +You return an object that has the complementary products to create and delete. + +#### createProductBuilderComplementaryProductsStep + +The `createProductBuilderComplementaryProductsStep` creates complementary products. + +To create the step, create the file `src/workflows/steps/create-product-builder-complementary-products.ts` with the following content: + +export const createProductBuilderComplementaryProductsStepHighlights = [ + ["16", "created", "Create the complementary products."], + ["31", "deleteProductBuilderComplementaries", "Rollback: delete created complementary products."] +] + +```ts title="src/workflows/steps/create-product-builder-complementary-products.ts" highlights={createProductBuilderComplementaryProductsStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { PRODUCT_BUILDER_MODULE } from "../../modules/product-builder" + +export type CreateProductBuilderComplementaryProductsStepInput = { + complementary_products: Array<{ + product_builder_id: string + product_id: string + }> +} + +export const createProductBuilderComplementaryProductsStep = createStep( + "create-product-builder-complementary-products", + async (input: CreateProductBuilderComplementaryProductsStepInput, { container }) => { + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + + const created = await productBuilderModuleService.createProductBuilderComplementaries( + input.complementary_products + ) + const createdArray = Array.isArray(created) ? created : [created] + + return new StepResponse(createdArray, { + createdIds: createdArray.map((p: any) => p.id), + }) + }, + async (compensationData, { container }) => { + if (!compensationData?.createdIds?.length) { + return + } + + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + await productBuilderModuleService.deleteProductBuilderComplementaries( + compensationData.createdIds + ) + } +) +``` + +The step receives the complementary products to create as input. + +In the step, you create the complementary products and return them. + +In the compensation function, you delete the created complementary products if an error occurs during the workflow's execution. + +#### deleteProductBuilderComplementaryProductsStep + +The `deleteProductBuilderComplementaryProductsStep` deletes complementary products. + +To create the step, create the file `src/workflows/steps/delete-product-builder-complementary-products.ts` with the following content: + +export const deleteProductBuilderComplementaryProductsStepHighlights = [ + ["17", "deleteProductBuilderComplementaries", "Delete the complementary products."], + ["31", "createProductBuilderComplementaries", "Rollback: recreate deleted complementary products."] +] + +```ts title="src/workflows/steps/delete-product-builder-complementary-products.ts" highlights={deleteProductBuilderComplementaryProductsStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { PRODUCT_BUILDER_MODULE } from "../../modules/product-builder" + +export type DeleteProductBuilderComplementaryProductsStepInput = { + complementary_products: Array<{ + id: string + product_id: string + product_builder_id: string + }> +} + +export const deleteProductBuilderComplementaryProductsStep = createStep( + "delete-product-builder-complementary-products", + async (input: DeleteProductBuilderComplementaryProductsStepInput, { container }) => { + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + + await productBuilderModuleService.deleteProductBuilderComplementaries( + input.complementary_products.map((p) => p.id) + ) + + return new StepResponse(input.complementary_products, { + deletedItems: input.complementary_products, + }) + }, + async (compensationData, { container }) => { + if (!compensationData?.deletedItems?.length) { + return + } + + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + await productBuilderModuleService.createProductBuilderComplementaries( + compensationData.deletedItems.map((p: any) => ({ + id: p.id, + product_builder_id: p.product_builder_id, + product_id: p.product_id, + })) + ) + } +) +``` + +This step receives complementary products to delete as input. + +In the step, you delete the complementary products. + +In the compensation function, you recreate the deleted complementary products if an error occurs during the workflow's execution. + +#### prepareProductBuilderAddonsStep + +The `prepareProductBuilderAddonsStep` receives the addon products from the workflow's input and returns which addon products should be created or deleted. + +To create the step, create the file `src/workflows/steps/prepare-product-builder-addons.ts` with the following content: + +export const prepareProductBuilderAddonsStepHighlights = [ + ["18", "existingAddons", "Get existing addon associations for this product builder."], + ["32", "toCreate", "Add addons to be created."], + ["40", "toDelete", "Add addons to be deleted."] +] + +```ts title="src/workflows/steps/prepare-product-builder-addons.ts" highlights={prepareProductBuilderAddonsStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { PRODUCT_BUILDER_MODULE } from "../../modules/product-builder" + +export type PrepareProductBuilderAddonsStepInput = { + product_builder_id: string + addon_products?: Array<{ + id?: string + product_id: string + }> +} + +export const prepareProductBuilderAddonsStep = createStep( + "prepare-product-builder-addons", + async (input: PrepareProductBuilderAddonsStepInput, { container }) => { + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + + // Get existing addon associations for this product builder + const existingAddons = await productBuilderModuleService.listProductBuilderAddons({ + product_builder_id: input.product_builder_id, + }) + + // Separate operations: create, update, and delete + const toCreate: any[] = [] + + // Process input products to determine creates + input.addon_products?.forEach((productData) => { + const existingAddon = existingAddons.find( + (a) => a.product_id === productData.product_id + ) + if (!existingAddon) { + // Create new addon product + toCreate.push({ + product_builder_id: input.product_builder_id, + product_id: productData.product_id, + }) + } + }) + + // Find products to delete (existing but not in input) + const toDelete = existingAddons.filter( + (product) => !input.addon_products?.some( + (p) => p.product_id === product.product_id + ) + ) + + return new StepResponse({ + toCreate, + toDelete, + }) + } +) +``` + +The step receives the ID of the product builder and the addon products to manage as input. + +In the step, you retrieve the existing addon products for the specified product builder and determine which products need to be created or deleted based on whether it exists in the input. + +You return an object that has the addon products to create and delete. + +#### createProductBuilderAddonsStep + +The `createProductBuilderAddonsStep` creates addon products. + +To create the step, create the file `src/workflows/steps/create-product-builder-addons.ts` with the following content: + +export const createProductBuilderAddonsStepHighlights = [ + ["16", "createdAddons", "Create the addon products."], + ["30", "deleteProductBuilderAddons", "Delete the product builder addons if an error occurs in the workflow."] +] + +```ts title="src/workflows/steps/create-product-builder-addons.ts" highlights={createProductBuilderAddonsStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { PRODUCT_BUILDER_MODULE } from "../../modules/product-builder" + +export type CreateProductBuilderAddonsStepInput = { + addon_products: Array<{ + product_builder_id: string + product_id: string + }> +} + +export const createProductBuilderAddonsStep = createStep( + "create-product-builder-addons", + async (input: CreateProductBuilderAddonsStepInput, { container }) => { + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + + const createdAddons = await productBuilderModuleService.createProductBuilderAddons( + input.addon_products + ) + + return new StepResponse(createdAddons, { + createdItems: createdAddons, + }) + }, + async (compensationData, { container }) => { + if (!compensationData?.createdItems?.length) { + return + } + + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + await productBuilderModuleService.deleteProductBuilderAddons( + compensationData.createdItems.map((a: any) => a.id) + ) + } +) +``` + +The step receives the addon products to create as input. + +In the step, you create the addon products and return them. + +In the compensation function, you delete the created addon products if an error occurs during the workflow's execution. + +#### deleteProductBuilderAddonsStep + +The `deleteProductBuilderAddonsStep` deletes addon products. + +To create the step, create the file `src/workflows/steps/delete-product-builder-addons.ts` with the following content: + +export const deleteProductBuilderAddonsStepHighlights = [ + ["17", "deleteProductBuilderAddons", "Delete the product builder addons."], + ["31", "createProductBuilderAddons", "Recreate the deleted product builder addons if an error occurs."] +] + +```ts title="src/workflows/steps/delete-product-builder-addons.ts" highlights={deleteProductBuilderAddonsStepHighlights} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { PRODUCT_BUILDER_MODULE } from "../../modules/product-builder" + +export type DeleteProductBuilderAddonsStepInput = { + addon_products: Array<{ + id: string + product_builder_id: string + product_id: string + }> +} + +export const deleteProductBuilderAddonsStep = createStep( + "delete-product-builder-addons", + async (input: DeleteProductBuilderAddonsStepInput, { container }) => { + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + + await productBuilderModuleService.deleteProductBuilderAddons( + input.addon_products.map((a) => a.id) + ) + + return new StepResponse(input.addon_products, { + deletedItems: input.addon_products, + }) + }, + async (compensationData, { container }) => { + if (!compensationData?.deletedItems?.length) { + return + } + + const productBuilderModuleService = container.resolve(PRODUCT_BUILDER_MODULE) + await productBuilderModuleService.createProductBuilderAddons( + compensationData.deletedItems.map((a: any) => ({ + id: a.id, + product_builder_id: a.product_builder_id, + product_id: a.product_id, + })) + ) + } +) +``` + +This step receives addon products to delete as input. + +In the step, you delete the addon products. + +In the compensation function, you recreate the deleted addon products if an error occurs during the workflow's execution. + +#### Create Workflow + +You now have the necessary steps to build the workflow that upserts a product builder configuration. Since the workflow is long, you'll create it in chunks. + +Start by creating the file `src/workflows/upsert-product-builder.ts` with the following content: + +```ts title="src/workflows/upsert-product-builder.ts" collapsibleLines="1-17" expandButtonLabel="Show Imports" +import { createWorkflow, parallelize, transform, when, WorkflowResponse } from "@medusajs/framework/workflows-sdk" +import { createRemoteLinkStep, dismissRemoteLinkStep } from "@medusajs/medusa/core-flows" +import { Modules } from "@medusajs/framework/utils" +import { createProductBuilderStep } from "./steps/create-product-builder" +import { prepareProductBuilderCustomFieldsStep } from "./steps/prepare-product-builder-custom-fields" +import { createProductBuilderCustomFieldsStep } from "./steps/create-product-builder-custom-fields" +import { updateProductBuilderCustomFieldsStep } from "./steps/update-product-builder-custom-fields" +import { deleteProductBuilderCustomFieldsStep } from "./steps/delete-product-builder-custom-fields" +import { prepareProductBuilderComplementaryProductsStep } from "./steps/prepare-product-builder-complementary-products" +import { createProductBuilderComplementaryProductsStep } from "./steps/create-product-builder-complementary-products" +import { deleteProductBuilderComplementaryProductsStep } from "./steps/delete-product-builder-complementary-products" +import { prepareProductBuilderAddonsStep } from "./steps/prepare-product-builder-addons" +import { createProductBuilderAddonsStep } from "./steps/create-product-builder-addons" +import { deleteProductBuilderAddonsStep } from "./steps/delete-product-builder-addons" +import { useQueryGraphStep } from "@medusajs/medusa/core-flows" +import { PRODUCT_BUILDER_MODULE } from "../modules/product-builder" + +export type UpsertProductBuilderWorkflowInput = { + product_id: string + custom_fields?: Array<{ + id?: string + name: string + type: string + is_required?: boolean + description?: string | null + }> + complementary_products?: Array<{ + id?: string + product_id: string + }> + addon_products?: Array<{ + id?: string + product_id: string + }> +} + +export const upsertProductBuilderWorkflow = createWorkflow( + "upsert-product-builder", + (input: UpsertProductBuilderWorkflowInput) => { + // TODO retrieve or create product builder + } +) +``` + +You create a workflow using the `createWorkflow` function. It accepts the workflow's unique name as a first parameter. + +It accepts as a second parameter a constructor function that holds the workflow's implementation. + +The function accepts an input object holding the details of the product builder to create or update, including its associated product ID, custom fields, complementary products, and addon products. + +The first part of the workflow is to retrieve or create the product builder configuration. So, replace the `TODO` with the following: + +export const upsertProductBuilderWorkflowHighlights1 = [ + ["1", "existingProductBuilder", "Retrieve the existing product builder."], + ["11", "when", "Function to run conditions."], + ["14", "", "Check whether the product builder doesn't exist."], + ["16", "createProductBuilderStep", "Create a new product builder."], + ["20", "transform", "Function to manipulate data."], + ["31", "createRemoteLinkStep", "Create a link between the builder and the product."], + ["36", "productBuilderId", "Extract the product builder ID."] +] + +```ts title="src/workflows/upsert-product-builder.ts" highlights={upsertProductBuilderWorkflowHighlights1} +const { data: existingProductBuilder } = useQueryGraphStep({ + entity: "product_builder", + fields: [ + "id", + ], + filters: { + product_id: input.product_id, + }, +}) + +const productBuilder = when({ + existingProductBuilder, + // @ts-ignore +}, ({ existingProductBuilder }) => existingProductBuilder.length === 0) + .then(() => { + const productBuilder = createProductBuilderStep({ + product_id: input.product_id, + }) + + const productBuilderLink = transform({ + productBuilder, + }, (data) => [{ + [PRODUCT_BUILDER_MODULE]: { + product_builder_id: data.productBuilder!.id, + }, + [Modules.PRODUCT]: { + product_id: data.productBuilder!.product_id, + }, + }]) + + const link = createRemoteLinkStep(productBuilderLink) + + return productBuilder + }) + +const productBuilderId = transform({ + existingProductBuilder, productBuilder, +}, (data) => data.productBuilder?.id || data.existingProductBuilder[0]!.id) + +// TODO manage custom fields +``` + +In this snippet, you: + +1. Try to retrieve the existing product builder using the `useQueryGraphStep`. + - This step uses [Query](!docs!/learn/fundamentals/module-links/query) to retrieve data across modules. +2. Use [when-then](!docs!/learn/fundamentals/workflows/conditions) to check whether the existing product builder was found. + - If there's no existing product builder, you create a new one using the `createProductBuilderStep`, then link it to the product using the `createRemoteLinkStep`. +3. Use [transform](!docs!/learn/fundamentals/workflows/variable-manipulation) to extract the product builder ID from either the existing or newly created product builder. + + + +In a workflow, you can't manipulate data or check conditions because Medusa stores an internal representation of the workflow on application startup. Learn more in the [Data Manipulation](!docs!/learn/fundamentals/workflows/variable-manipulation) and [Conditions](!docs!/learn/fundamentals/workflows/conditions) documentation. + + + +Next, you need to manage the custom fields passed in the input. Replace the new `TODO` in the workflow with the following: + +export const upsertProductBuilderWorkflowHighlights2 = [ + ["6", "prepareProductBuilderCustomFieldsStep", "Prepare custom fields for the product builder."], + ["11", "parallelize", "Function to run steps in parallel."], + ["12", "createProductBuilderCustomFieldsStep", "Create custom fields for the product builder."], + ["15", "updateProductBuilderCustomFieldsStep", "Update custom fields for the product builder."], + ["18", "deleteProductBuilderCustomFieldsStep", "Delete custom fields for the product builder."] +] + +```ts title="src/workflows/upsert-product-builder.ts" highlights={upsertProductBuilderWorkflowHighlights2} +// Prepare custom fields operations +const { + toCreate: customFieldsToCreate, + toUpdate: customFieldsToUpdate, + toDelete: customFieldsToDelete, +} = prepareProductBuilderCustomFieldsStep({ + product_builder_id: productBuilderId, + custom_fields: input.custom_fields, +}) + +parallelize( + createProductBuilderCustomFieldsStep({ + custom_fields: customFieldsToCreate, + }), + updateProductBuilderCustomFieldsStep({ + custom_fields: customFieldsToUpdate, + }), + deleteProductBuilderCustomFieldsStep({ + custom_fields: customFieldsToDelete, + }) +) + +// TODO manage complementary products and addons +``` + +In this portion, you use the `prepareProductBuilderCustomFieldsStep` to determine which custom fields need to be created, updated, or deleted. + +Then, you run the `createProductBuilderCustomFieldsStep`, `updateProductBuilderCustomFieldsStep`, and `deleteProductBuilderCustomFieldsStep` in parallel to manage the custom fields. + +Next, you need to manage the complementary products passed in the input. Replace the new `TODO` in the workflow with the following: + +export const upsertProductBuilderWorkflowHighlights3 = [ + ["5", "prepareProductBuilderComplementaryProductsStep", "Prepare complementary products for the product builder."], + ["14", "createProductBuilderComplementaryProductsStep", "Create complementary products."], + ["17", "deleteProductBuilderComplementaryProductsStep", "Delete complementary products."], + ["24", "complementaryProductLinks", "Links to create between complementary Medusa products."], + ["25", "deletedComplementaryProductLinks", "Links to dismiss between complementary Medusa products."], + ["54", "createRemoteLinkStep", "Create links between complementary and Medusa products."], + ["63", "dismissRemoteLinkStep", "Dismiss links between complementary and Medusa products."] +] + +```ts title="src/workflows/upsert-product-builder.ts" highlights={upsertProductBuilderWorkflowHighlights3} +// Prepare complementary products operations +const { + toCreate: complementaryProductsToCreate, + toDelete: complementaryProductsToDelete, +} = prepareProductBuilderComplementaryProductsStep({ + product_builder_id: productBuilderId, + complementary_products: input.complementary_products, +}) + +const [ + createdComplementaryProducts, + deletedComplementaryProducts, +] = parallelize( + createProductBuilderComplementaryProductsStep({ + complementary_products: complementaryProductsToCreate, + }), + deleteProductBuilderComplementaryProductsStep({ + complementary_products: complementaryProductsToDelete, + }) +) + +// Create remote links for complementary products +const { + complementaryProductLinks, + deletedComplementaryProductLinks, +} = transform({ + createdComplementaryProducts, + deletedComplementaryProducts, +}, (data) => { + return { + complementaryProductLinks: data.createdComplementaryProducts.map((item) => ({ + [PRODUCT_BUILDER_MODULE]: { + product_builder_complementary_id: item.id, + }, + [Modules.PRODUCT]: { + product_id: item.product_id, + }, + })), + deletedComplementaryProductLinks: data.deletedComplementaryProducts.map((item) => ({ + [PRODUCT_BUILDER_MODULE]: { + product_builder_complementary_id: item.id, + }, + [Modules.PRODUCT]: { + product_id: item.product_id, + }, + })), + } +}) + +when({ + complementaryProductLinks, +}, ({ complementaryProductLinks }) => complementaryProductLinks.length > 0) + .then(() => { + createRemoteLinkStep(complementaryProductLinks).config({ + name: "create-complementary-product-links", + }) + }) + +when({ + deletedComplementaryProductLinks, +}, ({ deletedComplementaryProductLinks }) => deletedComplementaryProductLinks.length > 0) + .then(() => { + dismissRemoteLinkStep(deletedComplementaryProductLinks) + }) +``` + +In this portion of the workflow, you: + +- Prepare which complementary products need to be created or deleted using the `prepareProductBuilderComplementaryProductsStep`. +- Run the `createProductBuilderComplementaryProductsStep` and `deleteProductBuilderComplementaryProductsStep` in parallel to manage the complementary products. +- Prepare the links to be created or deleted between the complementary products and the Medusa products. +- Create the links for the new complementary products. +- Dismiss the links for the deleted complementary products. + +Next, you need to manage the addon products passed in the input. Replace the new `TODO` in the workflow with the following: + +export const upsertProductBuilderWorkflowHighlights4 = [ + ["5", "prepareProductBuilderAddonsStep", "Prepare addon products for the product builder."], + ["11", "createProductBuilderAddonsStep", "Create addon products."], + ["14", "deleteProductBuilderAddonsStep", "Delete addon products."], + ["21", "addonProductLinks", "Links to create between addon Medusa products."], + ["22", "deletedAddonProductLinks", "Links to dismiss between addon Medusa products."], + ["51", "createRemoteLinkStep", "Create links between addon and Medusa products."], + ["60", "dismissRemoteLinkStep", "Dismiss links between addon and Medusa products."] +] + +```ts title="src/workflows/upsert-product-builder.ts" highlights={upsertProductBuilderWorkflowHighlights4} +// Prepare addons operations +const { + toCreate: addonsToCreate, + toDelete: addonsToDelete, +} = prepareProductBuilderAddonsStep({ + product_builder_id: productBuilderId, + addon_products: input.addon_products, +}) + +const [createdAddons, deletedAddons] = parallelize( + createProductBuilderAddonsStep({ + addon_products: addonsToCreate, + }), + deleteProductBuilderAddonsStep({ + addon_products: addonsToDelete, + }) +) + +// Create remote links for addon products +const { + addonProductLinks, + deletedAddonProductLinks, +} = transform({ + createdAddons, + deletedAddons, +}, (data) => { + return { + addonProductLinks: data.createdAddons.map((item) => ({ + [PRODUCT_BUILDER_MODULE]: { + product_builder_addon_id: item.id, + }, + [Modules.PRODUCT]: { + product_id: item.product_id, + }, + })), + deletedAddonProductLinks: data.deletedAddons.map((item) => ({ + [PRODUCT_BUILDER_MODULE]: { + product_builder_addon_id: item.id, + }, + [Modules.PRODUCT]: { + product_id: item.product_id, + }, + })), + } +}) + +when({ + addonProductLinks, +}, ({ addonProductLinks }) => addonProductLinks.length > 0) + .then(() => { + createRemoteLinkStep(addonProductLinks).config({ + name: "create-addon-product-links", + }) + }) + +when({ + deletedAddonProductLinks, +}, ({ deletedAddonProductLinks }) => deletedAddonProductLinks.length > 0) + .then(() => { + dismissRemoteLinkStep(deletedAddonProductLinks).config({ + name: "dismiss-addon-product-links", + }) + }) +// TODO retrieve and return the product builder configuration +``` + +This part of the workflow is similar to the complementary products management, but it handles addon products instead. You create and delete addon products, then create and dismiss links between them and Medusa products. + +Finally, you need to retrieve and return the product builder configuration. Replace the last `TODO` in the workflow with the following: + +export const upsertProductBuilderWorkflowHighlights5 = [ + ["1", "useQueryGraphStep", "Retrieve the product builder configuration."], +] + +```ts title="src/workflows/upsert-product-builder.ts" highlights={upsertProductBuilderWorkflowHighlights5} +const { data: productBuilders } = useQueryGraphStep({ + entity: "product_builder", + fields: [ + "id", + "product_id", + "custom_fields.*", + "complementary_products.*", + "complementary_products.product.*", + "addons.*", + "addons.product.*", + "created_at", + "updated_at", + ], + filters: { + product_id: input.product_id, + }, +}).config({ name: "get-product-builder" }) + +// @ts-ignore +return new WorkflowResponse({ + product_builder: productBuilders[0], +}) +``` + +You retrieve the product builder configuration again using `useQueryGraphStep`. + +A workflow must return an instance of `WorkflowResponse`. It receives as a parameter the data returned by the workflow, which is the product builder configuration. + +### b. Upsert Product Builder API Route + +Next, you'll create the API route that exposes the workflow's functionality to clients. + +An API route is created in a `route.ts` file under a sub-directory of the `src/api` directory. The path of the API route is the file's path relative to `src/api`. + + + +Refer to the [API routes](!docs!/learn/fundamentals/api-routes) documentation to learn more about them. + + + +Create the file `src/api/admin/products/[id]/builder/route.ts` with the following content: + +export const builderRouteHighlights = [ + ["8", "UpsertProductBuilderSchema", "Validation schema for request data."], + ["26", "POST", "Create or update product builder configuration."] +] + +```ts title="src/api/admin/products/[id]/builder/route.ts" highlights={builderRouteHighlights} +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework" +import { z } from "zod" +import { upsertProductBuilderWorkflow } from "../../../../../workflows/upsert-product-builder" + +export const UpsertProductBuilderSchema = z.object({ + custom_fields: z.array(z.object({ + id: z.string().optional(), + name: z.string(), + type: z.string(), + is_required: z.boolean().optional().default(false), + description: z.string().nullable().optional(), + })).optional(), + complementary_products: z.array(z.object({ + id: z.string().optional(), + product_id: z.string(), + })).optional(), + addon_products: z.array(z.object({ + id: z.string().optional(), + product_id: z.string(), + })).optional(), +}) + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { result } = await upsertProductBuilderWorkflow(req.scope) + .run({ + input: { + product_id: req.params.id, + ...req.validatedBody, + }, + }) + + res.json({ + product_builder: result.product_builder, + }) +} +``` + +First, you define a [Zod](https://zod.dev/) schema that represents the accepted request body. It includes optional custom fields, complementary products, and addon products. + +Then, you export a `POST` route handler function, which will expose a `POST` API route at `/admin/products/[id]/builder`. + +In the route handler, you execute the `upsertProductBuilderWorkflow` passing it the Medusa container, which is available in the `req.scope` property, and executing its `run` method. + +You return the product builder in the response. + +You'll test this API route later when you customize the Medusa Admin dashboard. + +#### Add Validation Middleware + +To validate the body parameters of requests sent to the API route, you need to apply a [middleware](!docs!/learn/fundamentals/api-routes/middlewares). + +To apply a middleware to a route, create the file `src/api/middlewares.ts` with the following content: + +export const middlewareHighlights = [ + ["7", "defineMiddlewares", "Define route middlewares."], + ["10", "matcher", "Match routes by path pattern."], + ["13", "validateAndTransformBody", "Validate request body."] +] + +```ts title="src/api/middlewares.ts" highlights={middlewareHighlights} +import { + defineMiddlewares, + validateAndTransformBody, +} from "@medusajs/framework/http" +import { UpsertProductBuilderSchema } from "./admin/products/[id]/builder/route" + +export default defineMiddlewares({ + routes: [ + { + matcher: "/admin/products/:id/builder", + methods: ["POST"], + middlewares: [ + validateAndTransformBody(UpsertProductBuilderSchema), + ], + }, + ], +}) +``` + +You apply the `validateAndTransformBody` middleware to the `POST` route of the `/admin/products/:id/builder` path, passing it the Zod schema you created in the route file. + +Any request that doesn't conform to the schema will receive a 400 Bad Request response. + + + +Refer to the [Middlewares](!docs!/learn/fundamentals/api-routes/middlewares) documentation to learn more. + + + +--- + +## Step 5: Retrieve Product Builder Data API Routes + +In this step, you'll create API routes that retrieve data useful for your admin customizations later. You'll implement API routes to: + +- Retrieve a product's builder configuration. +- Retrieve products that can be added as complementary products. +- Retrieve products that can be added as addon products. + +### a. Retrieve Product Builder Configuration API Route + +The first route you'll create is for retrieving a product's builder configuration. + +You'll make the API route available at the `/admin/products/:id/builder` path. So, add the following in the same `src/api/admin/products/[id]/builder/route.ts` file: + +export const getProductBuilderHighlights = [ + ["7", "productBuilders", "Retrieve product builder configurations."], +] + +```ts title="src/api/admin/products/[id]/builder/route.ts" highlights={getProductBuilderHighlights} +export const GET = async ( + req: AuthenticatedMedusaRequest<{ id: string }>, + res: MedusaResponse +) => { + const query = req.scope.resolve("query") + + const { data: productBuilders } = await query.graph({ + entity: "product_builder", + fields: [ + "id", + "product_id", + "custom_fields.*", + "complementary_products.*", + "complementary_products.product.*", + "addons.*", + "addons.product.*", + "created_at", + "updated_at", + ], + filters: { + product_id: req.params.id, + }, + }) + + if (productBuilders.length === 0) { + return res.status(404).json({ + message: `Product builder configuration not found for product ID: ${req.params.id}`, + }) + } + + res.json({ + product_builder: productBuilders[0], + }) +} +``` + +Since you export a `GET` route handler function, you expose a `GET` API route at `/admin/products/:id/builder`. + +In the route handler function, you resolve [Query](!docs!/learn/fundamentals/module-links/query) to retrieve the product builder configuration for the specified product ID. You also retrieve its custom fields, complementary products, and addon products. + +You return the product builder configuration in the response. + +### b. Retrieve Complementary Products API Route + +Next, you'll create an API route that retrieves products that can be added as complementary products for another product. This is useful to allow the admin users to select complementary products when configuring a product builder. + +To create the API route, create the file `src/api/admin/products/complementary/route.ts` with the following content: + +export const getComplementaryProductsRouteHighlights = [ + ["30", "$ne", "Exclude the product being configured."], + ["32", "tags", "Exclude products having `addon` tag."], + ["46", "status", "Exclude products that are not published."], + ["48", "pagination", "Pass pagination parameters."] +] + +```ts title="src/api/admin/products/complementary/route.ts" highlights={getComplementaryProductsRouteHighlights} +import { MedusaRequest, MedusaResponse } from "@medusajs/framework" +import { createFindParams } from "@medusajs/medusa/api/utils/validators" +import { z } from "zod" + +export const GetComplementaryProductsSchema = z.object({ + exclude_product_id: z.string(), +}).merge(createFindParams()) + +export const GET = async ( + req: MedusaRequest, + res: MedusaResponse +) => { + const { + exclude_product_id, + } = req.validatedQuery + + const query = req.scope.resolve("query") + + const { + data: products, + metadata, + } = await query.graph({ + entity: "product", + fields: [ + "*", + "variants.*", + ], + filters: { + id: { + $ne: exclude_product_id as string, + }, + tags: { + $or: [ + { + value: { + $eq: null, + }, + }, + { + value: { + $ne: "addon", + }, + }, + ], + }, + status: "published", + }, + pagination: req.queryConfig.pagination, + }) + + res.json({ + products, + limit: metadata?.take, + offset: metadata?.skip, + count: metadata?.count, + }) +} +``` + +You define a Zod schema that requires passing a `exclude_product_id` query parameter to filter out the current product from the list of complementary products. You merge the schema with the `createFindParams` schema to include pagination and sorting parameters. + +In the `GET` route handler, you retrieve the potential complementary products using Query. You apply the following filters on the products: + +1. Exclude the current product from the list by filtering out the `exclude_product_id`. +2. Exclude products that have the "addon" tag, as these can only be sold as addons. +3. Exclude products that are not published. + +You also apply pagination configurations using the `req.queryConfig.pagination` property. You'll learn how you can set these configurations in a bit. + +Finally, you return the list of products in the response with pagination metadata. + +#### Apply Query Validation and Configuration Middleware + +Next, you'll apply a middleware to validate the query parameters and apply pagination configurations to the API route. + +In `src/api/middlewares.ts`, add the following imports at the top of the file: + +```ts title="src/api/middlewares.ts" +import { + validateAndTransformQuery, +} from "@medusajs/framework/http" +import { GetComplementaryProductsSchema } from "./admin/products/complementary/route" +``` + +Then, add a new route object in `defineMiddlewares`: + +```ts title="src/api/middlewares.ts" +export default defineMiddlewares({ + routes: [ + // ... + { + matcher: "/admin/products/complementary", + methods: ["GET"], + middlewares: [ + validateAndTransformQuery(GetComplementaryProductsSchema, { + isList: true, + }), + ], + }, + ], +}) +``` + +You apply the `validateAndTransformQuery` middleware to the `GET` API route at `/admin/products/complementary`. The middleware accepts two parameters: + +1. The Zod schema to validate the query parameters. +2. An object of [Request Query Configurations](!docs!/learn/fundamentals/module-links/query#request-query-configurations). You enable the `isList` option to indicate that the pagination query parameters should be added as query configurations in the `req.queryConfig.pagination` object. + +### c. Retrieve Addon Products API Route + +Finally, you'll create an API route that retrieves products that can be added as addon products for another product. This is useful to allow the admin users to select addon products when configuring a product builder. + +To create the API route, create the file `src/api/admin/products/addons/route.ts` with the following content: + +export const getAddonProductsHighlights = [ + ["19", "tags", "Retrieve only products having the `addon` tag."], + ["22", "status", "Retrieve only published products."] +] + +```ts title="src/api/admin/products/addons/route.ts" highlights={getAddonProductsHighlights} +import { MedusaRequest, MedusaResponse } from "@medusajs/framework" + +export const GET = async ( + req: MedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve("query") + + const { + data: products, + metadata, + } = await query.graph({ + entity: "product", + fields: [ + "*", + "variants.*", + ], + filters: { + tags: { + value: "addon", + }, + status: "published", + }, + pagination: req.queryConfig.pagination, + }) + + res.json({ + products, + limit: metadata?.take, + offset: metadata?.skip, + count: metadata?.count, + }) +} +``` + +In the `GET` API route at `/admin/products/addons`, you retrieve the products that have the "addon" tag and are published. You return the products in the response with pagination data. + +#### Apply Query Configuration Middleware + +Since the API route should accept pagination query parameters, you need to apply the `validateAndTransformQuery` middleware to it. + +In `src/api/middlewares.ts`, add the following import at the top of the file: + +```ts title="src/api/middlewares.ts" +import { createFindParams } from "@medusajs/medusa/api/utils/validators" +``` + +Then, add a new route object in `defineMiddlewares`: + +```ts title="src/api/middlewares.ts" +export default defineMiddlewares({ + routes: [ + // ... + { + matcher: "/admin/products/addons", + methods: ["GET"], + middlewares: [ + validateAndTransformQuery(createFindParams(), { + isList: true, + }), + ], + }, + ], +}) +``` + +You apply the `validateAndTransformQuery` on the route to allow passing pagination query parameters, and enabling `isList` to populate the `req.queryConfig.pagination` object. + +You'll test out all of these routes in the next step. + +--- + +## Step 6: Add Admin Widget in Product Details Page + +In this step, you'll customize the Medusa Admin to allow admin users to manage a product's builder configurations. + +The Medusa Admin dashboard is customizable, allowing you to insert widgets into existing pages, or create new pages. + + + +Refer to the [Admin Development](!docs!/learn/fundamentals/admin) documentation to learn more. + + + +In this step, you'll create the components to manage a product's builder configurations, then inject a widget into the product details page to show the configurations and allow managing them. + +### a. Initialize JS SDK + +To send requests to the Medusa server, you'll use the [JS SDK](../../../js-sdk/page.mdx). It's already installed in your Medusa project, but you need to initialize it before using it in your customizations. + +Create the file `src/admin/lib/sdk.ts` with the following content: + +export const sdkHighlights = [ + ["3", "new Medusa", "Initialize the Medusa JS SDK."], + ["4", "baseUrl", "Backend server URL."], + ["5", "debug", "Enable debug mode in development."], + ["7", "type: \"session\"", "Use session-based authentication."] +] + +```ts title="src/admin/lib/sdk.ts" highlights={sdkHighlights} +import Medusa from "@medusajs/js-sdk" + +export const sdk = new Medusa({ + baseUrl: process.env.MEDUSA_BACKEND_URL || "http://localhost:9000", + debug: process.env.NODE_ENV === "development", + auth: { + type: "session", + }, +}) +``` + +Learn more about the initialization options in the [JS SDK](../../../js-sdk/page.mdx) reference. + +### b. Define Types + +Next, you'll define types that you'll use in your admin customizations. + +Create the file `src/admin/types.ts` with the following content: + +export const typesHighlights = [ + ["1", "ProductBuilderBase", "Base type for product builder configuration."], + ["8", "CustomFieldBase", "Base type for custom fields."], + ["16", "ComplementaryProductBase", "Base type for complementary products."], + ["25", "AddonProductBase", "Base type for addon products."], + ["35", "ProductBuilderResponse", "API response type for product builder configurations."], + ["44", "CustomField", "Type for custom fields in the form."], + ["52", "ComplementaryProduct", "Type for complementary products in the form."], + ["61", "AddonProduct", "Type for addon products in the form."] +] + +```ts title="src/admin/types.ts" highlights={typesHighlights} +export type ProductBuilderBase = { + id: string + product_id: string + created_at: string + updated_at: string +} + +export type CustomFieldBase = { + id: string + name: string + type: "text" | "number" + description?: string + is_required: boolean +} + +export type ComplementaryProductBase = { + id: string + product_id: string + product?: { + id: string + title: string + } +} + +export type AddonProductBase = { + id: string + product_id: string + product?: { + id: string + title: string + } +} + +// Product Builder API Response Types +export type ProductBuilderResponse = { + product_builder: ProductBuilderBase & { + custom_fields: CustomFieldBase[] + complementary_products: ComplementaryProductBase[] + addons: AddonProductBase[] + } +} + +// Form Data Types (for creating/updating) +export type CustomField = { + id?: string + name: string + type: "text" | "number" + description?: string + is_required: boolean +} + +export type ComplementaryProduct = { + id?: string + product_id: string + product?: { + id: string + title: string + } +} + +export type AddonProduct = { + id?: string + product_id: string + product?: { + id: string + title: string + } +} +``` + +You define the following types: + +- `ProductBuilderBase`: Base type for product builder configuration. +- `CustomFieldBase`: Base type for custom fields. +- `ComplementaryProductBase`: Base type for complementary products. +- `AddonProductBase`: Base type for addon products. +- `ProductBuilderResponse`: API response type for product builder configurations. +- `CustomField`: Type of custom fields in the form that creates or updates product builder configurations. +- `ComplementaryProduct`: Type of complementary products in the form that creates or updates product builder configurations. +- `AddonProduct`: Type of addon products in the form that creates or updates product builder configurations. + +### c. Custom Fields Tab Component + +To manage a product's builder configurations, you'll show a modal with tabs for custom fields, complementary products, and add-ons. + +You'll start by creating the custom fields tab component, which allows admin users to manage custom fields for a product's builder configuration. + +![Screenshot of how the custom fields tab will look like](https://res.cloudinary.com/dza7lstvk/image/upload/v1755089825/Medusa%20Resources/CleanShot_2025-08-13_at_15.56.15_2x_uwej4x.png) + +To create the component, create the file `src/admin/components/custom-fields-tab.tsx` with the following content: + +```tsx title="src/admin/components/custom-fields-tab.tsx" collapsibleLines="1-12" expandButtonLabel="Show Imports" +import { + Button, + Heading, + Input, + Label, + Select, + Checkbox, + Text, +} from "@medusajs/ui" +import { Trash } from "@medusajs/icons" +import { CustomField } from "../types" + +type CustomFieldsTabProps = { + customFields: CustomField[] + onCustomFieldsChange: (fields: CustomField[]) => void +} + +export const CustomFieldsTab = ({ + customFields, + onCustomFieldsChange, +}: CustomFieldsTabProps) => { + const addCustomField = () => { + const newFields = [ + ...customFields, + { + name: "", + type: "text" as const, + description: "", + is_required: false, + }, + ] + onCustomFieldsChange(newFields) + } + + const updateCustomField = (index: number, field: Partial) => { + const updated = [...customFields] + updated[index] = { ...updated[index], ...field } + onCustomFieldsChange(updated) + } + + const removeCustomField = (index: number) => { + const filtered = customFields.filter((_, i) => i !== index) + onCustomFieldsChange(filtered) + } + + return ( +
+
+
+ Custom Fields + +
+ + {customFields.length === 0 ? ( + No custom fields configured. + ) : ( +
+ {customFields.map((field, index) => ( +
+
+ + +
+
+
+ + updateCustomField(index, { name: e.target.value })} + placeholder="Field name" + /> +
+
+ + +
+
+
+ + updateCustomField(index, { description: e.target.value })} + placeholder="Provide helpful instructions for this field" + /> +
+
+ + updateCustomField(index, { is_required: !!checked }) + } + /> + +
+
+ ))} +
+ )} +
+
+ ) +} +``` + +This component receives the custom fields and a function to change them as an input. + +In the component, you show each custom field in its own section with fields for the name, type, description, and whether it's required. + +You also add buttons to add a new custom field or remove an existing one. + +When the admin user changes values of a custom field, creates a custom field, or deletes a custom field, you use the `onCustomFieldChange` callback to set the updated custom fields. + +### d. Complementary Products Tab Component + +Next, you'll create the complementary products tab component, which allows admin users to manage the complementary products for a product's builder configuration. + +![Screenshot of how the complementary products tab will look like](https://res.cloudinary.com/dza7lstvk/image/upload/v1755089830/Medusa%20Resources/CleanShot_2025-08-13_at_15.56.20_2x_jxix2n.png) + +Create the file `src/admin/components/complementary-products-tab.tsx` with the following content: + +```tsx title="src/admin/components/complementary-products-tab.tsx" collapsibleLines="1-13" expandButtonLabel="Show Imports" +import { AdminProduct } from "@medusajs/framework/types" +import { + Heading, + Checkbox, + createDataTableColumnHelper, + DataTable, + DataTablePaginationState, + useDataTable, +} from "@medusajs/ui" +import { useState } from "react" +import { useQuery } from "@tanstack/react-query" +import { sdk } from "../lib/sdk" +import { ComplementaryProduct } from "../types" + +type ComplementaryProductsTabProps = { + product: AdminProduct + complementaryProducts: ComplementaryProduct[] + onComplementaryProductSelection: (productId: string, checked: boolean) => void +} + +type ProductRow = { + id: string + title: string + status: string +} + +const columnHelper = createDataTableColumnHelper() + +export const ComplementaryProductsTab = ({ + product, + complementaryProducts, + onComplementaryProductSelection, +}: ComplementaryProductsTabProps) => { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 20, + }) + + // Fetch products for selection with pagination + const { data: productsData, isLoading } = useQuery({ + queryKey: ["products", "complementary", pagination], + queryFn: async () => { + const query = new URLSearchParams({ + limit: pagination.pageSize.toString(), + offset: (pagination.pageIndex * pagination.pageSize).toString(), + exclude_product_id: product.id, + }) + const response: any = await sdk.client.fetch( + `/admin/products/complementary?${query.toString()}` + ) + return { + products: response.products, + count: response.count, + } + }, + }) + + const columns = [ + columnHelper.display({ + id: "select", + header: "Select", + cell: ({ row }) => { + const isChecked = !!complementaryProducts.find( + (cp) => cp.product_id === row.original.id + ) + return ( + + onComplementaryProductSelection(row.original.id, !!checked) + } + className="my-2" + /> + ) + }, + }), + columnHelper.accessor("title", { + header: "Product", + }), + ] + + const table = useDataTable({ + data: productsData?.products || [], + columns, + rowCount: productsData?.count || 0, + getRowId: (row) => row.id, + isLoading, + pagination: { + state: pagination, + onPaginationChange: setPagination, + }, + }) + + return ( +
+ + + Complementary Products + + + + +
+ ) +} +``` + +This component receives the following props: + +- `product`: The main product being configured. +- `complementaryProducts`: A set of selected complementary product IDs. +- `onComplementaryProductSelection`: A function to handle selection changes. + +In the component, you retrieve the products using the [retrieve complementary products API route](#b-retrieve-complementary-products-api-route) you created. You show these products in a table with a checkbox for selection. + +When a product is selected or de-selected, you use the `onComplementaryProductSelection` function to update the list of selected complementary products. + + + +You use [Tanstack Query](https://tanstack.com/query/latest) to send requests with the JS SDK, which simplifies data fetching and caching. + + + +### e. Addon Products Tab Component + +Next, you'll create the last tab of the product builder configuration modal. It will allow the admin user to select addons of the product. + +![Screenshot of how the addons tab will look like](https://res.cloudinary.com/dza7lstvk/image/upload/v1755089831/Medusa%20Resources/CleanShot_2025-08-13_at_15.56.24_2x_zmryk1.png) + +To create the component, create the file `src/admin/components/addons-tab.tsx` with the following content: + +```tsx title="src/admin/components/addons-tab.tsx" collapsibleLines="1-12" expandButtonLabel="Show Imports" +import { + Heading, + Checkbox, + createDataTableColumnHelper, + DataTable, + DataTablePaginationState, + useDataTable, +} from "@medusajs/ui" +import { useState } from "react" +import { useQuery } from "@tanstack/react-query" +import { sdk } from "../lib/sdk" +import { AddonProduct } from "../types" + +type AddonsTabProps = { + addonProducts: AddonProduct[] + onAddonProductSelection: (productId: string, checked: boolean) => void +} + +type ProductRow = { + id: string + title: string + status: string +} + +const columnHelper = createDataTableColumnHelper() + +export const AddonsTab = ({ + addonProducts, + onAddonProductSelection, +}: AddonsTabProps) => { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 20, + }) + + // Fetch addon products with pagination + const { data: addonsData, isLoading } = useQuery({ + queryKey: ["products", "addon", pagination], + queryFn: async () => { + const response: any = await sdk.client.fetch( + `/admin/products/addons?limit=${pagination.pageSize}&offset=${pagination.pageIndex * pagination.pageSize}` + ) + return { + addons: response.products || [], + count: response.count || 0, + } + }, + }) + + const columns = [ + columnHelper.display({ + id: "select", + header: "Select", + cell: ({ row }) => { + const isChecked = !!addonProducts.find( + (ap) => ap.product_id === row.original.id + ) + return ( + + onAddonProductSelection(row.original.id, !!checked) + } + className="my-2" + /> + ) + }, + }), + columnHelper.accessor("title", { + header: "Product", + }), + ] + + const tableData = addonsData?.addons || [] + + const table = useDataTable({ + data: tableData, + columns, + rowCount: addonsData?.count || 0, + getRowId: (row) => row.id, + isLoading, + pagination: { + state: pagination, + onPaginationChange: setPagination, + }, + }) + + return ( +
+ + + Addon Products + + + + +
+ ) +} +``` + +The component receives the following props: + +- `addonProducts`: A set of selected addon product IDs. +- `onAddonProductSelection`: A callback function to handle addon product selection changes. + +In the component, you retrieve the products using the [retrieve addon products API route](#c-retrieve-addon-products-api-route) you created. You show these products in a table with a checkbox for selection. + +When a product is selected or de-selected, you use the `onAddonProductSelection` function to update the list of selected addon products. + +### d. Product Builder Configurations Modal + +Now that you have the components for managing custom fields, complementary products, and add-ons, you'll create a modal component that wraps these tabs in a modal. + +Create the file `src/admin/components/product-builder-modal.tsx` with the following content: + +```tsx title="src/admin/components/product-builder-modal.tsx" collapsibleLines="1-21" expandButtonLabel="Show Imports" +import { + Button, + FocusModal, + Heading, + toast, + ProgressTabs, +} from "@medusajs/ui" +import { useState, useEffect } from "react" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { sdk } from "../lib/sdk" +import { + CustomField, + ComplementaryProduct, + ProductBuilderResponse, + AddonProduct, +} from "../types" +import { AdminProduct } from "@medusajs/framework/types" +import { ComplementaryProductsTab } from "./complementary-products-tab" +import { AddonsTab } from "./addons-tab" +import { CustomFieldsTab } from "./custom-fields-tab" + +type ProductBuilderModalProps = { + open: boolean + onOpenChange: (open: boolean) => void + product: AdminProduct + initialData?: ProductBuilderResponse["product_builder"] + onSuccess: () => void +} + +export const ProductBuilderModal = ({ + open, + onOpenChange, + product, + initialData, + onSuccess, +}: ProductBuilderModalProps) => { + const [customFields, setCustomFields] = useState([]) + const [complementaryProducts, setComplementaryProducts] = useState([]) + const [addonProducts, setAddonProducts] = useState([]) + const [currentTab, setCurrentTab] = useState("custom-fields") + + const queryClient = useQueryClient() + + // Helper function to determine tab status + const getTabStatus = (tabName: string): "not-started" | "in-progress" | "completed" => { + const isCurrentTab = currentTab === tabName + switch (tabName) { + case "custom-fields": + return customFields.length > 0 ? isCurrentTab ? + "in-progress" : "completed" : + "not-started" + case "complementary": + return complementaryProducts.length > 0 ? isCurrentTab ? + "in-progress" : "completed" : + "not-started" + case "addons": + return addonProducts.length > 0 ? isCurrentTab ? + "in-progress" : "completed" : + "not-started" + default: + return "not-started" + } + } + + // Load initial data when modal opens + useEffect(() => { + setCustomFields(initialData?.custom_fields || []) + setComplementaryProducts(initialData?.complementary_products || []) + setAddonProducts(initialData?.addons || []) + + // Reset to first tab when modal opens + setCurrentTab("custom-fields") + }, [open, initialData]) + + const { mutateAsync: saveConfiguration, isPending: isSaving } = useMutation({ + mutationFn: async (data: any) => { + return await sdk.client.fetch(`/admin/products/${product.id}/builder`, { + method: "POST", + body: data, + }) + }, + onSuccess: () => { + toast.success("Builder configuration saved successfully") + queryClient.invalidateQueries({ + queryKey: ["product-builder", product.id], + }) + onSuccess() + }, + onError: (error: any) => { + toast.error(`Failed to save configuration: ${error.message}`) + }, + }) + + const handleSave = async () => { + try { + await saveConfiguration({ + custom_fields: customFields, + complementary_products: complementaryProducts.map((cp) => ({ + id: cp.id, + product_id: cp.product_id, + })), + addon_products: addonProducts.map((ap) => ({ + id: ap.id, + product_id: ap.product_id, + })), + }) + } catch (error) { + toast.error(`Error saving configuration: ${error instanceof Error ? error.message : "Unknown error"}`) + } + } + + const handleComplementarySelection = (productId: string, checked: boolean) => { + setComplementaryProducts((prev) => { + if (checked) { + return [ + ...prev, + { + product_id: productId, + }, + ] + } + + return prev.filter((cp) => cp.product_id !== productId) + }) + } + + const handleAddonSelection = (productId: string, checked: boolean) => { + setAddonProducts((prev) => { + if (checked) { + return [ + ...prev, + { + product_id: productId, + }, + ] + } + + return prev.filter((ap) => ap.product_id !== productId) + }) + } + + const handleNextTab = () => { + if (currentTab === "custom-fields") { + setCurrentTab("complementary") + } else if (currentTab === "complementary") { + setCurrentTab("addons") + } + } + + const isLastTab = currentTab === "addons" + + // TODO render modal +} +``` + +The `ProductBuilderModal` accepts the following props: + +- `open`: Whether the modal is open. +- `onOpenChange`: Function to change the open state. +- `product`: The product being configured. +- `initialData`: The initial data for the product builder. +- `onSuccess`: Function to execute when the configuration is saved successfully. + +In the component, you define the following variables and functions: + +- `customFields`: Stores the custom fields entered by the admin. +- `complementaryProducts`: Stores the complementary products selected by the admin. +- `addonProducts`: Stores the addon products selected by the admin. +- `currentTab`: The current active tab. +- `queryClient`: The Tanstack Query client which is useful to refetch data. +- `getTabStatus`: A function to get the status of each tab. +- `saveConfiguration`: A mutation to save the configuration when the admin submits them. +- `handleSave`: A function that executes the mutation to save the configurations. +- `handleComplementarySelection`: A function to update the selected complementary products. +- `handleAddonSelection`: A function to update the selected addon products. +- `handleNextTab`: A function to open the next tab. +- `isLastTab`: A boolean indicating if the current tab is the last tab. + +Next, to render the form, replace the `TODO` in the component with the following: + +```tsx title="src/admin/components/product-builder-modal.tsx" +return ( + + + + Builder Configuration + + + + + + Custom Fields + + + Complementary Products + + + Addon Products + + + + + + + + + + + + + + + + + +
+
+ + +
+
+
+
+
+) +``` + +You display a [Focus Modal](!ui!/components/focus-modal) that shows the tabs with each of their content. + +The modal has a button to move between tabs, then save the changes when the admin user reaches the last tab. + +### e. Add Widget to Product Details Page + +Finally, you'll create the widget that will be injected to the product details page. + +Create the file `src/admin/widgets/product-builder-widget.tsx` with the following content: + +```tsx title="src/admin/widgets/product-builder-widget.tsx" collapsibleLines="1-12" expandButtonLabel="Show Imports" +import { defineWidgetConfig } from "@medusajs/admin-sdk" +import { Container, Heading, Text, Button } from "@medusajs/ui" +import { useQuery } from "@tanstack/react-query" +import { sdk } from "../lib/sdk" +import { + DetailWidgetProps, + AdminProduct, +} from "@medusajs/framework/types" +import { useState } from "react" +import { ProductBuilderModal } from "../components/product-builder-modal" +import { ProductBuilderResponse } from "../types" + +const ProductBuilderWidget = ({ + data: product, +}: DetailWidgetProps) => { + const [modalOpen, setModalOpen] = useState(false) + + const { data, isLoading, refetch } = useQuery({ + queryFn: () => sdk.client.fetch(`/admin/products/${product.id}/builder`), + queryKey: ["product-builder", product.id], + retry: false, + }) + + const formatSummary = (items: any[], getTitle: (item: any) => string) => { + if (!items || items.length === 0) {return "-"} + if (items.length === 1) {return getTitle(items[0])} + return `${getTitle(items[0])} + ${items.length - 1} more` + } + + const customFieldsSummary = formatSummary( + data?.product_builder?.custom_fields || [], + (field) => field.name + ) + + const complementaryProductsSummary = formatSummary( + data?.product_builder?.complementary_products || [], + (item) => item.product?.title || "Unnamed Product" + ) + + const addonsSummary = formatSummary( + data?.product_builder?.addons || [], + (item) => item.product?.title || "Unnamed Product" + ) + + return ( + <> + +
+ Builder Configuration + +
+
+ {isLoading ? ( + Loading... + ) : ( + <> +
+ + Custom Fields + + + + {customFieldsSummary} + +
+
+ + Complementary Products + + + + {complementaryProductsSummary} + +
+
+ + Addon Products + + + + {addonsSummary} + +
+ + )} +
+
+ + { + refetch() + setModalOpen(false) + }} + /> + + ) +} + +export const config = defineWidgetConfig({ + zone: "product.details.side.after", +}) + +export default ProductBuilderWidget +``` + +A widget file must export: + +- A default React component. This component renders the widget's UI. +- A `config` object created with `defineWidgetConfig` from the Admin SDK. It accepts an object with the `zone` property that indicates where the widget will be rendered in the Medusa Admin dashboard. + +In the widget's component, you retrieve the product builder configuration of the current product, if available. Then, you show a summary of the configurations, with a button to edit the configurations. + +When the edit button is clicked, the product builder modal is shown with the tabs for custom fields, complementary products, and addon products. + +### Test the Admin Widget + +To test out the admin widget for product builder configurations: + +1. Start the Medusa application with the following command: + +```bash npm2yarn +npm run dev +``` + +2. Open the Medusa Admin dashboard at `localhost:9000/app` and login. +3. Go to Settings -> Product Tags. +4. Create a tag with the value `addon`. +5. Go back to the Products page, and choose an existing product to mark as an addon. +6. Change the product's tag from the Organize section. +7. Go back to the Products page, and choose an existing product to manage its builder configurations. +8. Scroll down to the end of the product's details page. You'll find a new "Builder Configuration" section. This is the widget you inserted. + +![Builder configuration widget in the product details page](https://res.cloudinary.com/dza7lstvk/image/upload/v1755096182/Medusa%20Resources/CleanShot_2025-08-13_at_17.30.06_2x_hnfsdl.png) + +9. Click on the Edit button to edit the configurations. +10. Add custom fields such as engravings, select complementary products such as keyboard, and add add-ons like a warranty. +11. Once you're done, click on the "Save Configuration" button. The modal will be closed and you can see the updated configurations in the widget. + +![Updated builder configuration data showing in the widget](https://res.cloudinary.com/dza7lstvk/image/upload/v1755096296/Medusa%20Resources/CleanShot_2025-08-13_at_17.44.45_2x_zqonoy.png) + +--- + +## Step 7: Customize Product Page on Storefront + +In this step, you'll customize the product details page on the storefront to show the product builder configurations. + +Alongside the variant options like color and size, which are already available in Medusa, you'll show: + +- The custom fields, allowing the customer to enter their values. +- The complementary products, allowing the customer to add them to the cart alongside the main product. +- The addon products, allowing the customer to add them to the cart as part of the main product. + + + +The Next.js Starter Storefront was installed in a separate directory from Medusa. The directory's name is `{your-project}-storefront`. + +So, if your Medusa application's directory is `medusa-product-builder`, you can find the storefront by going back to the parent directory and changing to the `medusa-product-builder-storefront` directory: + +```bash +cd ../medusa-product-builder-storefront # change based on your project name +``` + + + +### a. Define Types for Product Builder Configurations + +You'll start by defining types that you'll use in your storefront customizations. + +In `src/types/global.ts`, add the following import at the top of the file: + +```ts title="src/types/global.ts" badgeLabel="Storefront" badgeColor="blue" +import { + StoreProduct, +} from "@medusajs/types" +``` + +Then, add the following type definitions at the end of the file: + +export const storefrontTypesHighlights = [ + ["1", "ProductBuilderCustomField", "Type for custom input fields."], + ["9", "ProductBuilderComplementaryProduct", "Type for suggested products."], + ["14", "ProductBuilderAddon", "Type for addon products."], + ["19", "ProductBuilder", "Main product builder type."], + ["28", "ProductWithBuilder", "Extended product with builder data."], + ["33", "CustomFieldValue", "Type for custom field values."], + ["38", "ComplementarySelection", "Type for complementary product selections."], + ["46", "AddonSelection", "Type for addon product selections."], + ["55", "BuilderConfiguration", "Type for product builder configurations."] +] + +```ts title="src/types/global.ts" highlights={storefrontTypesHighlights} badgeLabel="Storefront" badgeColor="blue" +export type ProductBuilderCustomField = { + id: string + name: string + type: "text" | "number" + description?: string + is_required: boolean +} + +export type ProductBuilderComplementaryProduct = { + id: string + product: StoreProduct +} + +export type ProductBuilderAddon = { + id: string + product: StoreProduct +} + +export type ProductBuilder = { + id: string + product_id: string + custom_fields: ProductBuilderCustomField[] + complementary_products: ProductBuilderComplementaryProduct[] + addons: ProductBuilderAddon[] +} + +// Extended Product Type with Product Builder +export type ProductWithBuilder = StoreProduct & { + product_builder?: ProductBuilder +} + +// Product Builder Configuration Types +export type CustomFieldValue = { + field_id: string + value: string | number +} + +export type ComplementarySelection = { + product_id: string + variant_id: string + title: string + thumbnail?: string + price: number +} + +export type AddonSelection = { + product_id: string + variant_id: string + title: string + thumbnail?: string + price: number + quantity: number +} + +export type BuilderConfiguration = { + custom_fields: CustomFieldValue[] + complementary_products: ComplementarySelection[] + addons: AddonSelection[] +} +``` + +You define the following types: + +- `ProductBuilderCustomField`: A custom field in a product builder configuration. +- `ProductBuilderComplementaryProduct`: A complementary product in a product builder configuration. +- `ProductBuilderAddon`: An add-on product in a product builder configuration. +- `ProductBuilder`: The main product builder configuration object. +- `ProductWithBuilder`: A Medusa product with an associated product builder configuration. +- `CustomFieldValue`: A value entered by the customer for a custom field. +- `ComplementarySelection`: A selected complementary product in a product builder configuration. +- `AddonSelection`: A selected add-on product in a product builder configuration. +- `BuilderConfiguration`: The overall builder configuration chosen by the customer for a product. + +### b. Retrieve Product Builder Configuration + +Next, you need to retrieve the builder configuration for a product when the customer views its details page. + +Since you've defined a link between the product and its builder configuration, you can retrieve the builder configuration of a product by specifying it in the `fields` query parameter of the [List Products API Route](!api!/store#products_getproducts). + +In `src/lib/data/products.ts`, add the following import at the top of the file: + +```ts title="src/lib/data/products.ts" badgeLabel="Storefront" badgeColor="blue" +import { ProductWithBuilder } from "../../types/global" +``` + +Then, change the return type of the `listProducts` function: + +```ts title="src/lib/data/products.ts" badgeLabel="Storefront" badgeColor="blue" highlights={[["6"]]} +export const listProducts = async ({ + // ... +}: { + // ... +}): Promise<{ + response: { products: ProductWithBuilder[]; count: number } + // ... +}> => { + // ... +} +``` + +Next, find the `sdk.client.fetch` call inside the `listProducts` function and change its type argument: + +```ts title="src/lib/data/products.ts" badgeLabel="Storefront" badgeColor="blue" highlights={[["2"]]} +return sdk.client + .fetch<{ products: ProductWithBuilder[]; count: number }>( + // ... + ) +``` + +Next, find the `fields` query parameter and add to it the product builder data: + +```ts title="src/lib/data/products.ts" badgeLabel="Storefront" badgeColor="blue" highlights={[["7"]]} +return sdk.client + .fetch<{ products: ProductWithBuilder[]; count: number }>( + `/store/products`, + { + query: { + fields: + "*variants.calculated_price,+variants.inventory_quantity,+metadata,+tags,*product_builder,*product_builder.custom_fields,*product_builder.complementary_products,*product_builder.complementary_products.product,*product_builder.complementary_products.product.variants,*product_builder.addons,*product_builder.addons.product,*product_builder.addons.product.variants", + // ... + }, + // ... + } + ) +``` + +You retrieve for each product its builder configurations, its custom fields, complementary products, and add-on products. You also retrieve the product and variant details of the complementary and addon products. + +Finally, change the return type of the `listProductsWithSort` to also include the product builder data: + +```ts title="src/lib/data/products.ts" badgeLabel="Storefront" badgeColor="blue" highlights={[["6"]]} +export const listProductsWithSort = async ({ + // ... +}: { + // ... +}): Promise<{ + response: { products: ProductWithBuilder[]; count: number } + // ... +}> => { + // ... +} +``` + +### c. Add Product Builder Configuration Utilities + +Next, you'll add utility functions that are useful in your customizations. + +Create the file `src/lib/util/product-builder.ts` with the following content: + +export const productBuilderUtilityHighlights = [ + ["4", "hasProductBuilder", "Check if a product has a product builder configuration."], + ["11", "hasCustomFields", "Check if a product has custom fields."], + ["18", "hasComplementaryProducts", "Check if a product has complementary products."], + ["25", "hasAddons", "Check if a product has addons."] +] + +```ts title="src/lib/util/product-builder.ts" badgeLabel="Storefront" badgeColor="blue" highlights={productBuilderUtilityHighlights} +import { ProductWithBuilder, ProductBuilder, LineItemWithBuilderMetadata } from "../../types/global" + +// Utility function to check if a product has builder configuration +export const hasProductBuilder = ( + product: ProductWithBuilder +): product is ProductWithBuilder & { product_builder: ProductBuilder } => { + return !!product.product_builder +} + +// Utility function to check if a product has custom fields +export const hasCustomFields = ( + product: ProductWithBuilder +): boolean => { + return hasProductBuilder(product) && product.product_builder.custom_fields.length > 0 +} + +// Utility function to check if a product has complementary products +export const hasComplementaryProducts = ( + product: ProductWithBuilder +): boolean => { + return hasProductBuilder(product) && product.product_builder.complementary_products.length > 0 +} + +// Utility function to check if a product has addons +export const hasAddons = ( + product: ProductWithBuilder +): boolean => { + return hasProductBuilder(product) && product.product_builder.addons.length > 0 +} +``` + +You define the following utilities: + +- `hasProductBuilder`: Checks if a product has a product builder configuration. +- `hasCustomFields`: Checks if a product has custom fields. +- `hasComplementaryProducts`: Checks if a product has complementary products. +- `hasAddons`: Checks if a product has addons. + +### d. Implement Product Builder Configuration Component + +In this section, you'll implement the component that will show the product builder configurations on the product details page. It will show inputs for custom fields, and variant selection for complementary and addon products. + +![Screenshot of how the product builder configuration component will look like](https://res.cloudinary.com/dza7lstvk/image/upload/v1755098570/Medusa%20Resources/CleanShot_2025-08-13_at_18.22.29_2x_zdtjlk.png) + +You'll first create the `VariantSelectionRow` component that you'll use to show the complementary and addon product variants. + +Create the file `src/modules/products/components/product-builder-config/variant-selector.tsx` with the following content: + +export const variantSelectorHighlights = [ + ["11", "variant", "The variant to display."], + ["12", "product", "The product that the variant belongs to."], + ["13", "isSelected", "Whether the variant is selected."], + ["14", "isLoading", "Whether the variant price is loading."], + ["15", "onToggle", "Function to call when the variant is toggled."] +] + +```tsx title="src/modules/products/components/product-builder-config/variant-selector.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={variantSelectorHighlights} +"use client" + +import { HttpTypes } from "@medusajs/types" +import { + Badge, + Text, +} from "@medusajs/ui" +import { getProductPrice } from "../../../../lib/util/get-product-price" + +type VariantSelectionRowProps = { + variant: HttpTypes.StoreProductVariant + product: HttpTypes.StoreProduct + isSelected: boolean + isLoading: boolean + onToggle: (productId: string, variantId: string, title: string, thumbnail: string | undefined, price: number) => void +} + +const VariantSelector: React.FC = ({ + variant, + product, + isSelected, + isLoading, + onToggle, +}) => { + const { + calculated_price: price = 0, + calculated_price_number: priceNumber = 0, + } = getProductPrice({ + product, + variantId: variant.id, + }).variantPrice || {} + + const inStock = !variant.manage_inventory || variant.allow_backorder || ( + variant.manage_inventory && (variant.inventory_quantity || 0) > 0 + ) + + return ( + + ) +} + +export default VariantSelector +``` + +This component accepts the following props: + +- `variant`: The variant being displayed. +- `product`: The product that the variant belongs to. +- `isSelected`: Whether the variant is selected. +- `isLoading`: Whether the variant's price is currently being loaded. +- `onToggle`: A function to toggle the variant selection. + +In the component, you show the variant's details and allow customers to toggle its selection. + +Next, you'll create the component that will show the product builder. + +Create the file `src/modules/products/components/product-builder-config/index.tsx` with the following content: + +export const productBuilderConfigHighlights = [ + ["27", "product", "The product being configured."], + ["28", "countryCode", "The country code for pricing and availability."], + ["29", "onConfigurationChange", "Callback for when the configuration changes."], + ["30", "onValidationChange", "Callback for when the validation state changes."], + ["40", "customFields", "State for custom fields' values."], + ["41", "complementaryProducts", "State for selected complementary products."], + ["42", "addons", "State for selected addon products."], + ["45", "isLoadingPrices", "State indicating if product prices are being loaded."], + ["46", "productPrices", "Map storing loaded product prices."], + ["53", "builder", "The product builder configuration."], + ["56", "handleCustomFieldChange", "Updates the custom fields state."], + ["69", "handleComplementaryToggle", "Toggles the selection of complementary products."], + ["86", "handleAddonToggle", "Toggles the selection of addons."] +] + +```tsx title="src/modules/products/components/product-builder-config/index.tsx" collapsibleLines="1-25" expandButtonLabel="Show Imports" badgeLabel="Storefront" badgeColor="blue" highlights={productBuilderConfigHighlights} +"use client" + +import { useState, useEffect } from "react" +import { HttpTypes } from "@medusajs/types" +import { + Text, + Input, +} from "@medusajs/ui" +import Divider from "@modules/common/components/divider" +import { + ProductWithBuilder, + BuilderConfiguration, + CustomFieldValue, + ComplementarySelection, + AddonSelection, +} from "../../../../types/global" +import { + hasProductBuilder, + hasCustomFields, + hasComplementaryProducts, + hasAddons, +} from "@lib/util/product-builder" +import { listProducts } from "@lib/data/products" +import VariantSelector from "./variant-selector" + +type ProductBuilderConfigProps = { + product: ProductWithBuilder + countryCode: string + onConfigurationChange: (config: BuilderConfiguration) => void + onValidationChange: (isValid: boolean) => void +} + +const ProductBuilderConfig: React.FC = ({ + product, + countryCode, + onConfigurationChange, + onValidationChange, +}) => { + // Configuration state + const [customFields, setCustomFields] = useState([]) + const [complementaryProducts, setComplementaryProducts] = useState([]) + const [addons, setAddons] = useState([]) + + // UI state + const [isLoadingPrices, setIsLoadingPrices] = useState(false) + const [productPrices, setProductPrices] = useState>(new Map()) + + // Early return if no product builder + if (!hasProductBuilder(product)) { + return null + } + + const builder = product.product_builder + + // Custom field handlers + const handleCustomFieldChange = (fieldId: string, value: string | number) => { + setCustomFields((prev) => { + const existing = prev.find((f) => f.field_id === fieldId) + if (existing) { + return prev.map((f) => { + return f.field_id === fieldId ? { ...f, value } : f + }) + } + return [...prev, { field_id: fieldId, value }] + }) + } + + // Complementary product handlers + const handleComplementaryToggle = ( + productId: string, + variantId: string, + title: string, + thumbnail: string | undefined, + price: number + ) => { + setComplementaryProducts((prev) => { + const prevIndex = prev.findIndex((p) => p.variant_id === variantId) + if (prevIndex !== -1) { + return [...prev].splice(prevIndex, 1) + } + return [...prev, { product_id: productId, variant_id: variantId, title, thumbnail, price }] + }) + } + + // Addon handlers + const handleAddonToggle = ( + productId: string, + variantId: string, + title: string, + thumbnail: string | undefined, + price: number + ) => { + setAddons((prev) => { + const prevIndex = prev.findIndex((p) => p.variant_id === variantId) + if (prevIndex !== -1) { + return [...prev].splice(prevIndex, 1) + } + return [...prev, { product_id: productId, variant_id: variantId, title, thumbnail, price, quantity: 1 }] + }) + } + + const showCustomFields = hasCustomFields(product) + const showComplementaryProducts = hasComplementaryProducts(product) + const showAddons = hasAddons(product) + + // TODO add useEffect statements +} + +export default ProductBuilderConfig +``` + +The `ProductBuilderConfig` component accepts the following props: + +- `product`: The product being configured. +- `countryCode`: The country code for pricing and availability. +- `onConfigurationChange`: Callback for when the configuration changes. +- `onValidationChange`: Callback for when the validation state changes. + +In the component, you define the following variables and functions: + +- `customFields`: Stores custom fields' values. +- `complementaryProducts`: Stores selected complementary product details. +- `addons`: Stores selected addon product details. +- `isLoadingPrices`: Indicates if the product prices are being loaded. +- `productPrices`: Stores the loaded product prices. +- `builder`: The product builder configuration. +- `handleCustomFieldChange`: Updates the custom fields state. +- `handleComplementaryToggle`: Toggles the selection of complementary products. +- `handleAddonToggle`: Toggles the selection of addons. + +Next, you'll add a `useEffect` statements that call the `onConfigurationChange` and `onValidationChange` callbacks when the configuration changes. Replace the `TODO` with the following: + +```tsx title="src/modules/products/components/product-builder-config/index.tsx" badgeLabel="Storefront" badgeColor="blue" +// Update configuration when any field changes +useEffect(() => { + onConfigurationChange({ + custom_fields: customFields, + complementary_products: complementaryProducts, + addons: addons, + }) +}, [customFields, complementaryProducts, addons, onConfigurationChange]) + +// Validate required fields and notify parent +useEffect(() => { + // Check required custom fields + const requiredCustomFields = builder.custom_fields.filter((field) => field.is_required) + const customFieldsValid = requiredCustomFields.every((field) => { + const fieldValue = customFields.find((cf) => cf.field_id === field.id)?.value + return fieldValue !== undefined && fieldValue !== "" && fieldValue !== 0 + }) + + onValidationChange(customFieldsValid) +}, [customFields, builder, onValidationChange]) + +// TODO add more useEffect statements +``` + +You add a `useEffect` call that triggers the `onConfigurationChange` callback when configurations are updated, and another that validates the custom fields and triggers the `onValidationChange` callback when the validation state changes. + +You'll need one more `useEffect` statement that loads the prices of complementary and addon product variants. Replace the `TODO` with the following: + +```tsx title="src/modules/products/components/product-builder-config/index.tsx" badgeLabel="Storefront" badgeColor="blue" +// Fetch product prices for complementary products and addons +useEffect(() => { + const fetchProductPrices = async () => { + const productIds = new Set([ + ...builder.complementary_products.map((comp) => comp.product.id!), + ...builder.addons.map((addon) => addon.product.id!), + ]) + + if (productIds.size === 0) { + return + } + + setIsLoadingPrices(true) + + try { + // Fetch all products with their pricing information + const { response } = await listProducts({ + queryParams: { + id: Array.from(productIds), + limit: productIds.size, + }, + countryCode, + }) + + const priceMap = new Map() + response.products.forEach((product) => { + if (product.id) { + priceMap.set(product.id, product) + } + }) + + setProductPrices(priceMap) + } catch (error) { + console.error("Error fetching product prices:", error) + } finally { + setIsLoadingPrices(false) + } + } + + fetchProductPrices() +}, [builder.complementary_products, builder.addons, countryCode]) + +// TODO add return statement +``` + +This `useEffect` hook is triggered whenever the country code, complementary products, or addons change, ensuring that the latest pricing information is fetched for the selected products. + +You fetch the prices using the `listProducts` function, and you store the prices in a map. You'll use this map to display the prices of complementary and addon product variants. + +Finally, you need to add a `return` statement to the `ProductBuilderConfig` component. Replace the `TODO` with the following: + +```tsx title="src/modules/products/components/product-builder-config/index.tsx" badgeLabel="Storefront" badgeColor="blue" +return ( +
+ {/* Custom Fields Section */} + {showCustomFields && ( + <> +
+ {builder.custom_fields.map((field) => { + const currentValue = customFields.find((f) => f.field_id === field.id)?.value || "" + + return ( +
+
+ {field.name} + {field.is_required && ( + * + )} +
+ {field.description && ( + + {field.description} + + )} + + handleCustomFieldChange( + field.id, + field.type === "number" ? parseFloat(e.target.value) || 0 : e.target.value + )} + placeholder={`Enter ${field.name.toLowerCase()}`} + /> +
+ ) + })} +
+ + + )} + + {/* Complementary Products Section */} + {showComplementaryProducts && ( + <> +
+ {builder.complementary_products + .map((compProduct) => { + const product = compProduct.product + const productWithPrices = productPrices.get(product.id!) + + return ( +
+ Add a {product.title} + + Complete your setup with perfectly matched accessories and essentials + + +
+ {(productWithPrices?.variants || product.variants || []).map((variant) => { + const isSelected = complementaryProducts.some((p) => p.variant_id === variant.id) + + return ( + + ) + })} +
+
+ ) + })} +
+ + + )} + + {/* Addons Section */} + {showAddons && ( + <> +
+
+ Protect & Enhance Your Purchase +
+ + Add peace of mind with premium features + + +
+ {builder.addons + .map((addon) => { + const product = addon.product + const productWithPrices = productPrices.get(product.id!) + + return ( +
+ {(productWithPrices?.variants || product.variants || []).map((variant) => { + const isSelected = addons.some((a) => a.variant_id === variant.id) + + return ( + + ) + })} +
+ ) + })} +
+
+ {/* Only add separator if not the last section */} + {isLoadingPrices && } + + )} + + {/* Loading State */} + {isLoadingPrices && ( +
+ Loading prices... +
+ )} + + {(showCustomFields || showComplementaryProducts || showAddons) && } +
+) +``` + +You display a separate section for each custom field, complementary product, and addon in the product builder configuration. You also use the `VariantSelector` component to display the variants of each complementary and addon product. + +### e. Modify Price Component to Include Builder Prices + +When a customer chooses complementary and addon products, the price shown on the product page should reflect that selection. So, you need to modify the pricing component to accept the builder configuration, and update the displayed price accordingly. + +In `src/modules/products/components/product-price/index.tsx`, add the followng imports at the top of the file: + +```tsx title="src/modules/products/components/product-price/index.tsx" badgeLabel="Storefront" badgeColor="blue" +import { BuilderConfiguration } from "../../../../types/global" +import { convertToLocale } from "@lib/util/money" +``` + +Then, replace the `ProductPrice` component with the following: + +export const productPriceHighlights = [ + ["8", "builderConfig", "Add builder configuration prop."], + ["18", "calculateTotalPrice", "Calculate total price including builder configuration."], + ["57", "finalPrice", "Use instead of `selectedPrice`"] +] + +```tsx title="src/modules/products/components/product-price/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={productPriceHighlights} +export default function ProductPrice({ + product, + variant, + builderConfig, +}: { + product: HttpTypes.StoreProduct + variant?: HttpTypes.StoreProductVariant + builderConfig?: BuilderConfiguration | null +}) { + const { cheapestPrice, variantPrice } = getProductPrice({ + product, + variantId: variant?.id, + }) + + const selectedPrice = variant ? variantPrice : cheapestPrice + + // Calculate total price including builder configuration + const calculateTotalPrice = () => { + if (!selectedPrice) {return null} + + let totalPrice = selectedPrice.calculated_price_number || 0 + let totalOriginalPrice = selectedPrice.original_price_number || selectedPrice.calculated_price_number || 0 + + if (builderConfig) { + // Add complementary products prices + builderConfig.complementary_products.forEach((comp) => { + totalPrice += comp.price || 0 + totalOriginalPrice += comp.price || 0 + }) + + // Add addons prices + builderConfig.addons.forEach((addon) => { + const addonPrice = (addon.price || 0) * (addon.quantity || 1) + totalPrice += addonPrice + totalOriginalPrice += addonPrice + }) + } + + const currencyCode = selectedPrice.currency_code || "USD" + + return { + calculated_price_number: totalPrice, + original_price_number: totalOriginalPrice, + calculated_price: convertToLocale({ + amount: totalPrice, + currency_code: currencyCode, + }), + original_price: convertToLocale({ + amount: totalOriginalPrice, + currency_code: currencyCode, + }), + price_type: selectedPrice.price_type, + percentage_diff: selectedPrice.percentage_diff, + } + } + + const finalPrice = calculateTotalPrice() + + if (!finalPrice) { + return
+ } + + return ( +
+ + {!variant && "From "} + + {finalPrice.calculated_price} + + + {finalPrice.price_type === "sale" && ( + <> +

+ Original: + + {finalPrice.original_price} + +

+ + -{finalPrice.percentage_diff}% + + + )} +
+ ) +} +``` + +You make the following key changes: + +- Add the `builderConfig` prop to the `ProductPrice` component. +- Add a `calculateTotalPrice` function to compute the total price including the builder configuration. +- Remove the existing condition on `selectedPrice`, and replace it instead with a condition on `finalPrice`. The condition's body is still the same. +- Modify the return statement to use `finalPrice` instead of `selectedPrice`. + +### f. Display Product Builder on Product Page + +Finally, to display the product builder component on the product details page, you need to modify two components: + +- `ProductActions` that displays the product variant options with an add-to-cart button. +- `MobileActions` that displays the product variant options in a mobile-friendly format. + +#### Customize ProductActions + +You'll start with modifying the `ProductActions` component to include the product builder. + +In `src/modules/products/components/product-actions/index.tsx`, add the following imports: + +```tsx title="src/modules/products/components/product-actions/index.tsx" badgeLabel="Storefront" badgeColor="blue" +import ProductBuilderConfig from "../product-builder-config" +import { ProductWithBuilder, BuilderConfiguration } from "../../../../types/global" +import { hasProductBuilder } from "@lib/util/product-builder" +``` + +Next, change the type of the `product` prop to `ProductWithBuilder`: + +```tsx title="src/modules/products/components/product-actions/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["2"]]} +type ProductActionsProps = { + product: ProductWithBuilder + // ... +} +``` + +Then, in the `ProductActions` component, add the following state variables and `useEffect` hook: + +export const productActionHighlights = [ + ["6", "builderConfig", "The product's builder configurations."], + ["7", "isBuilderConfigValid", "Whether the builder configuration is valid."] +] + +```tsx title="src/modules/products/components/product-actions/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={productActionHighlights} +export default function ProductActions({ + product, + disabled, +}: ProductActionsProps) { + // ... + const [builderConfig, setBuilderConfig] = useState(null) + const [isBuilderConfigValid, setIsBuilderConfigValid] = useState(true) + + // Initialize validation state for products without builder + useEffect(() => { + if (!hasProductBuilder(product)) { + setIsBuilderConfigValid(true) + } + }, [product]) + + // ... +} +``` + +You define two variables: + +- `builderConfig`: Holds the configuration for the product builder, if it exists. +- `isBuilderConfigValid`: Tracks the validity of the builder configuration. + +You also add a `useEffect` hook to initialize the builder configuration state when the product changes. + +Next, you'll make updates to the `return` statement. Find the `ProductPrice` usage in the `return` statement and replace it with the following: + +```tsx title="src/modules/products/components/product-actions/index.tsx" badgeLabel="Storefront" badgeColor="blue" +return ( + <> + {/* ... */} + {hasProductBuilder(product) && ( + <> + + + )} + + + {/* ... */} + +) +``` + +You display the `ProductBuilderConfig` component before the price, and you pass the builder configurations to the `ProductPrice` component. + +Finally, find the add-to-cart button and replace it with the following: + +```tsx title="src/modules/products/components/product-actions/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["12"], ["23"], ["24"]]} +return ( + <> + {/* ... */} + + {/* ... */} + +) +``` + +You modify the button's `disabled` prop to also account for the validity of the product builder configuration, and you show the correct button text based on that validity. + +#### Customize MobileActions + +Next, you'll customize the `MobileActions` to show the correct price and button text in mobile view. + +In `src/modules/products/components/product-actions/mobile-actions.tsx`, add the following imports at the top of the file: + +```tsx title="src/modules/products/components/product-actions/mobile-actions.tsx" badgeLabel="Storefront" badgeColor="blue" +import { BuilderConfiguration } from "../../../../types/global" +import { convertToLocale } from "@lib/util/money" +import { hasProductBuilder } from "@lib/util/product-builder" +``` + +Next, add the following props to the `MobileActionsProps` type: + +```tsx title="src/modules/products/components/product-actions/mobile-actions.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["3"], ["4"]]} +type MobileActionsProps = { + // ... + builderConfig?: BuilderConfiguration | null + isBuilderConfigValid?: boolean +} +``` + +The component now accepts the builder configuration and its validity state as props. + +Next, add the props to the destructured parameter of the `MobileActions` component: + +```tsx title="src/modules/products/components/product-actions/mobile-actions.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["3"], ["4"]]} +const MobileActions: React.FC = ({ + // ... + builderConfig, + isBuilderConfigValid = true, +}: MobileActionsProps) => { + // ... +} +``` + +After that, find the `selectedPrice` variable and replace it with the following: + +```tsx title="src/modules/products/components/product-actions/mobile-actions.tsx" badgeLabel="Storefront" badgeColor="blue" +const selectedPrice = useMemo(() => { + if (!price) { + return null + } + const { variantPrice, cheapestPrice } = price + const basePrice = variantPrice || cheapestPrice || null + + if (!basePrice) {return null} + + // Calculate total price including builder configuration + let totalPrice = basePrice.calculated_price_number || 0 + + if (builderConfig) { + // Add complementary products prices + builderConfig.complementary_products.forEach((comp) => { + totalPrice += comp.price || 0 + }) + + // Add addons prices + builderConfig.addons.forEach((addon) => { + const addonPrice = (addon.price || 0) * (addon.quantity || 1) + totalPrice += addonPrice + }) + } + + const currencyCode = basePrice.currency_code || "USD" + + return { + ...basePrice, + calculated_price_number: totalPrice, + calculated_price: convertToLocale({ + amount: totalPrice, + currency_code: currencyCode, + }), + } +}, [price, builderConfig]) +``` + +Similar to the `ProductPrice` component, you set the selected price to the total price calculated from the builder configuration. This ensures that the correct price is displayed in the mobile view as well. + +Finally, in the `return` statement, find the add-to-cart button and replace it with the following: + +```tsx title="src/modules/products/components/product-actions/mobile-actions.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["9"], ["19"], ["20"]]} +return ( + <> + {/* ... */} + + {/* ... */} + +) +``` + +Similar to the `ProductActions` component, you ensure the button's disabled state and text match the builder configuration's validity. + +### Test Product Details Page + +To test out the product details page in the Next.js Starter Storefront: + +1. Start the Medusa application with the following command: + +```bash npm2yarn badgeLabel="Medusa Application" badgeColor="green" +npm run dev +``` + +2. Start the Next.js Starter Storefront with the following command: + +```bash npm2yarn badgeLabel="Storefront" badgeColor="blue" +npm run dev +``` + +3. In the storefront, go to Menu -> Store. +4. Click on the product that has builder configurations. + +You should see the custom fields, complementary products, and addons on the product's page. + +![Product details page with builder configuations](https://res.cloudinary.com/dza7lstvk/image/upload/v1755101580/Medusa%20Resources/CleanShot_2025-08-13_at_19.12.48_2x_tf4goa.png) + +While you can enter custom values and select variants, you still can't add the product variant with its builder configurations to the cart. You'll support that in the next step. + +--- + +## Step 8: Add Product with Builder Configurations to Cart + +In this step, you'll create a workflow that adds products with their builder configurations to the cart, then expose that functionality in an API route that you can send requests to from the storefront. + +### a. Create Workflow + +The workflow will validate the builder configurations, add the main product variant to the cart, then add the complementary and addon products as separate line items. You'll also associate the items with one another using their metadata. + +The workflow will have the following steps: + + + +You only need to implement the `validateProductBuilderConfigurationStep`, as Medusa provides the rest. + +#### validateProductBuilderConfigurationStep + +The `validateProductBuilderConfigurationStep` ensures the chosen builder configurations are valid before proceeding with the cart addition. + +To create the step, create the file `src/workflows/steps/validate-product-builder-configuration.ts` with the following content: + +export const validateProductBuilderConfigurationStepHighlights1 = [ + ["21", "productBuilder", "Retrieve the product's builder configurations."], + ["36", "productBuilder", "Throw an error if the product doesn't have builder configurations."] +] + +```ts title="src/workflows/steps/validate-product-builder-configuration.ts" highlights={validateProductBuilderConfigurationStepHighlights1} +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { MedusaError } from "@medusajs/framework/utils" + +export type ValidateProductBuilderConfigurationStepInput = { + product_id: string + custom_field_values?: Record + complementary_product_variants?: string[] + addon_variants?: string[] +} + +export const validateProductBuilderConfigurationStep = createStep( + "validate-product-builder-configuration", + async ({ + product_id, + custom_field_values, + complementary_product_variants, + addon_variants, + }: ValidateProductBuilderConfigurationStepInput, { container }) => { + const query = container.resolve("query") + + const { data: [productBuilder] } = await query.graph({ + entity: "product_builder", + fields: [ + "*", + "custom_fields.*", + "complementary_products.*", + "complementary_products.product.variants.*", + "addons.*", + "addons.product.variants.*", + ], + filters: { + product_id, + }, + }) + + if (!productBuilder) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Product builder configuration not found for product ID: ${product_id}` + ) + } + + // TODO validate custom fields, complementary products, and addon products + } +) +``` + +This step receives the product ID and its builder configurations as input. + +In the step, you resolve Query and retrieve the product's builder configurations. If the product builder is not found, you throw an error. + +Next, you need to validate the custom fields to ensure they match the product builder custom fields. Replace the `TODO` with the following: + +export const validateProductBuilderConfigurationStepHighlights2 = [ + ["1", "", "Validate that the product supports custom fields."], + ["18", "", "Validate that required custom fields are provided."], + ["28", "", "Validate that custom field values match their defined types."] +] + +```ts title="src/workflows/steps/validate-product-builder-configuration.ts" highlights={validateProductBuilderConfigurationStepHighlights2} +if ( + !productBuilder.custom_fields.length && + custom_field_values && Object.keys(custom_field_values).length > 0 +) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Product doesn't support custom fields.` + ) +} + +for (const field of productBuilder.custom_fields) { + if (!field) { + continue + } + const value = custom_field_values?.[field.name] + + // Check required fields + if (field.is_required && (!value || value === "")) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Custom field "${field.name}" is required` + ) + } + + // Validate field type + if (value !== undefined && value !== "" && field.type === "number" && isNaN(Number(value))) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Custom field "${field.name}" must be a number` + ) + } +} + +// TODO validate complementary products and addon products +``` + +You validate the selected custom fields to ensure that: + +- The product supports custom fields. +- Each required custom field is provided and has a valid value. +- The values of custom fields match their defined types (e.g., numbers are actually numbers). + +Next, you need to validate the complementary products and addon products. Replace the `TODO` with the following: + +export const validateProductBuilderConfigurationStepHighlights3 = [ + ["7", "", "Throw an error if the complementary product variants are not supported."], + ["20", "", "Throw an error if the addon product variants are not supported."], + ["27", "", "Return the product builder configurations."] +] + +```ts title="src/workflows/steps/validate-product-builder-configuration.ts" highlights={validateProductBuilderConfigurationStepHighlights3} +const invalidComplementary = complementary_product_variants?.filter( + (id) => !productBuilder.complementary_products.some((cp) => + cp?.product?.variants.some((variant) => variant.id === id) + ) +) + +if ((invalidComplementary?.length || 0) > 0) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Invalid complementary product variants: ${invalidComplementary!.join(", ")}` + ) +} + +const invalidAddons = addon_variants?.filter( + (id) => !productBuilder.addons.some((addon) => + addon?.product?.variants.some((variant) => variant.id === id) + ) +) + +if ((invalidAddons?.length || 0) > 0) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Invalid addon product variants: ${invalidAddons!.join(", ")}` + ) +} + +return new StepResponse(productBuilder) +``` + +You apply the same validation to complementary and addon products. You make sure the selected product variants exist in the product builder's complementary and addon products. + +Finally, if all configurations are valid, you return the product builder configurations. + +#### Implement Workflow + +You can now implement the workflow that adds products with builder configurations to the cart. + +Create the file `src/workflows/add-product-builder-to-cart.ts` with the following content: + +export const addToCartWorkflowHighlights = [ + ["24", "validateProductBuilderConfigurationStep", "Validate user selections."], +] + +```ts title="src/workflows/add-product-builder-to-cart.ts" collapsibleLines="1-9" expandButtonLabel="Show Imports" highlights={addToCartWorkflowHighlights} +import { + createWorkflow, + WorkflowResponse, + transform, + when, +} from "@medusajs/framework/workflows-sdk" +import { addToCartWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows" +import { validateProductBuilderConfigurationStep } from "./steps/validate-product-builder-configuration" + +type AddProductBuilderToCartInput = { + cart_id: string + product_id: string + variant_id: string + quantity?: number + custom_field_values?: Record + complementary_product_variants?: string[] // Array of product IDs + addon_variants?: string[] // Array of addon product IDs +} + +export const addProductBuilderToCartWorkflow = createWorkflow( + "add-product-builder-to-cart", + (input: AddProductBuilderToCartInput) => { + // Step 1: Validate the product builder configuration and selections + const productBuilder = validateProductBuilderConfigurationStep({ + product_id: input.product_id, + custom_field_values: input.custom_field_values, + complementary_product_variants: input.complementary_product_variants, + addon_variants: input.addon_variants, + }) + + // TODO add main, complementary, and addon product variants to the cart + } +) +``` + +The workflow accepts the cart, product, variant, and builder configuration information as input. + +So far, you only validate the product builder configuration using the step you created earlier. If the validation fails, the workflow will stop executing. + +Next, you need to add the main product variant to the cart. Replace the `TODO` with the following: + +export const addProductBuilderToCartWorkflowHighlights2 = [ + ["11", "product_builder_id", "Store the product builder ID in the metadata."], + ["12", "custom_fields", "Store custom fields in the metadata."], + ["21", "is_builder_main_product", "Set `is_builder_main_product` flag for the main product."], + ["26", "addToCartWorkflow", "Add the main product to the cart."] +] + +```ts title="src/workflows/add-product-builder-to-cart.ts" highlights={addProductBuilderToCartWorkflowHighlights2} +// Step 2: Add main product to cart +const addMainProductData = transform({ + input, + productBuilder, +}, (data) => ({ + cart_id: data.input.cart_id, + items: [{ + variant_id: data.input.variant_id, + quantity: data.input.quantity || 1, + metadata: { + product_builder_id: data.productBuilder?.id, + custom_fields: Object.entries(data.input.custom_field_values || {}) + .map(([field_id, value]) => { + const field = data.productBuilder?.custom_fields.find((f) => f?.id === field_id) + return { + field_id, + name: field?.name, + value, + } + }), + is_builder_main_product: true, + }, + }], +})) + +addToCartWorkflow.runAsStep({ + input: addMainProductData, +}) + +// TODO add complementary and addon product variants to cart +``` + +You prepare the data to add the main product variant to the cart. You include in the item's metadata the product builder ID, any custom fields, and a flag to identify it as the main product in the builder configuration. + +After that, you use the [addToCartWorkflow](/references/medusa-workflows/addToCartWorkflow) to add the main product to the cart. + +Next, you need to add the complementary and addon product variants to the cart. Replace the `TODO` with the following: + +export const addProductBuilderToCartWorkflowHighlights3 = [ + ["3", "items_to_add", "Complementary and addon items to add to the cart."], + ["4", "main_item_update", "Update main product line item metadata."], + ["30", "main_product_line_item_id", "Store the ID of the main item in the `metadata`."], + ["37", "main_product_line_item_id", "Store the ID of the main item in the `metadata`."], + ["38", "is_addon", "Set `is_addon` flag for addon products."], + ["48", "cart_line_item_id", "Store the line item's ID in the cart for later reference."], + ["63", "addToCartWorkflow", "Add complementary and addon products to the cart."], + ["72", "updateLineItemInCartWorkflow", "Update main product line item metadata with its cart line item ID."] +] + +```ts title="src/workflows/add-product-builder-to-cart.ts" highlights={addProductBuilderToCartWorkflowHighlights3} +// Step 5: Add complementary and addon products +const { + items_to_add: moreItemsToAdd, + main_item_update: mainItemUpdate, +} = transform({ + input, + cartWithMainProduct, +}, (data) => { + if (!data.input.complementary_product_variants?.length && !data.input.addon_variants?.length) { + return {} + } + + // Find the main product line item (most recent addition with builder metadata) + const mainLineItem = data.cartWithMainProduct[0].items.find((item: any) => + item.metadata?.is_builder_main_product === true + ) + + if (!mainLineItem) { + return {} + } + + return { + items_to_add: { + cart_id: data.input.cart_id, + items: [ + ...(data.input.complementary_product_variants?.map((complementaryProductVariant) => ({ + variant_id: complementaryProductVariant, + quantity: 1, + metadata: { + main_product_line_item_id: mainLineItem.id, + }, + })) || []), + ...(data.input.addon_variants?.map((addonVariant) => ({ + variant_id: addonVariant, + quantity: 1, + metadata: { + main_product_line_item_id: mainLineItem.id, + is_addon: true, + }, + })) || []), + ], + }, + main_item_update: { + item_id: mainLineItem.id, + cart_id: data.cartWithMainProduct[0].id, + update: { + metadata: { + cart_line_item_id: mainLineItem.id, + }, + }, + }, + } +}) + +when({ + moreItemsToAdd, + mainItemUpdate, +}, ({ + moreItemsToAdd, + mainItemUpdate, +}) => !!moreItemsToAdd && moreItemsToAdd.items.length > 0 && !!mainItemUpdate) +.then(() => { + addToCartWorkflow.runAsStep({ + input: { + cart_id: moreItemsToAdd!.cart_id, + items: moreItemsToAdd!.items, + }, + }) + // @ts-ignore + .config({ name: "add-more-products-to-cart" }) + + updateLineItemInCartWorkflow.runAsStep({ + input: mainItemUpdate!, + }) +}) + +// TODO retrieve and return updated cart details +``` + +First, you retrieve the cart after adding the main product to get its line items. + +Then, you prepare the data to add the complementary and addon products to the cart. You include the main product line item ID in their metadata to associate them with the main product. Also, you set the `is_addon` flag for addon products. + +You also prepare the data to update the main product line item's metadata with its cart line item ID. This allows you to reference it after the order is placed, since the `metadata` is moved to the order line item's `metadata`. + +Finally, you add the complementary and addon products to the cart using the `addToCartWorkflow`, and update the product's `metadata` with the cart line item ID. + +The last thing you need to do is retrieve the updated cart details after adding all items and return them. Replace the `TODO` with the following: + +```ts title="src/workflows/add-product-builder-to-cart.ts" +// Step 6: Fetch the final updated cart +const { data: updatedCart } = useQueryGraphStep({ + entity: "cart", + fields: ["*", "items.*"], + filters: { + id: input.cart_id, + }, + options: { + throwIfKeyNotFound: true, + }, +}).config({ name: "get-final-cart" }) + +return new WorkflowResponse({ + cart: updatedCart[0], +}) +``` + +You retrieve the final cart details after all items have been added, and you return the updated cart. + +### b. Create API Route + +Next, you'll create the API route that executes the above workflow. + +To create the API route, create the file `src/api/store/carts/[id]/product-builder/route.ts` with the following content: + +export const addToCartApiHighlights = [ + ["5", "AddBuilderProductSchema", "Define the Zod schema for request validation."], + ["23", "addProductBuilderToCartWorkflow", "Execute the workflow to add product builder to cart."], + ["31", "cart", "Return the updated cart in the response."] +] + +```ts title="src/api/store/carts/[id]/product-builder/route.ts" highlights={addToCartApiHighlights} +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { addProductBuilderToCartWorkflow } from "../../../../../workflows/add-product-builder-to-cart" +import { z } from "zod" + +export const AddBuilderProductSchema = z.object({ + product_id: z.string(), + variant_id: z.string(), + quantity: z.number().optional().default(1), + custom_field_values: z.record(z.any()).optional().default({}), + complementary_product_variants: z.array(z.string()).optional().default([]), + addon_variants: z.array(z.string()).optional().default([]), +}) + +export async function POST( + req: MedusaRequest< + z.infer + >, + res: MedusaResponse +) { + + const cartId = req.params.id + + const { result } = await addProductBuilderToCartWorkflow(req.scope).run({ + input: { + cart_id: cartId, + ...req.validatedBody, + }, + }) + + res.json({ + cart: result.cart, + }) +} +``` + +You define a Zod schema to validate the request body, then you expose a `POST` API route at `/store/carts/[id]/product-builder`. + +In the route handler, you execute the `addProductBuilderToCartWorkflow` with the validated request body. You return the updated cart in the response. + +### c. Add Validation Middleware + +To validate the request body before it reaches the API route, you need to add a validation middleware. + +In `src/api/middlewares.ts`, add the following import at the top of the file: + +```ts title="src/api/middlewares.ts" +import { AddBuilderProductSchema } from "./store/carts/[id]/product-builder/route" +``` + +Then, add the following object to the `routes` array in `defineMiddlewares`: + +```ts title="src/api/middlewares.ts" +export default defineMiddlewares({ + routes: [ + // ... + { + matcher: "/store/carts/:id/product-builder", + methods: ["POST"], + middlewares: [ + validateAndTransformBody(AddBuilderProductSchema), + ], + }, + ], +}) +``` + +You apply the `validateAndTransformBody` middleware to the `/store/carts/:id/product-builder` route, passing it the Zod schema you created for validation. + +You'll test out this API route and functionality when you customize the storefront next. + +--- + +## Step 9: Customize Cart in Storefront + +In this step, you'll customize the storefront to: + +- Support adding products with builder configurations to the cart. +- Display addon products with their main product in the cart. +- Display custom field values in the cart. + +### a. Use Add Product Builder to Cart API Route + +You'll start by customizing existing add-to-cart functionality to use the new API route you created in the previous step. + +#### Define Line Item Types + +In `src/types/global.ts`, add the following type definitions useful for your cart customizations: + +```ts title="src/types/global.ts" badgeLabel="Storefront" badgeColor="blue" +export type BuilderLineItemMetadata = { + is_builder_main_product?: boolean + main_product_line_item_id?: string + product_builder_id?: string + custom_fields?: { + field_id: string + name?: string + value: string + }[] + is_addon?: boolean + cart_line_item_id?: string +} + +export type LineItemWithBuilderMetadata = StoreCartLineItem & { + metadata?: BuilderLineItemMetadata +} +``` + +You define the `BuilderLineItemMetadata` type to include all relevant metadata for line items that are part of a product builder configuration, and the `LineItemWithBuilderMetadata` type extends the existing line item type to include this metadata. + +#### Identify Product Builder Items Utility + +Next, you need a utility function to identify whether a line item belongs to a product with builder configurations. + +In `src/lib/util/product-builder.ts`, add the following import at the top of the file: + +```ts title="src/lib/util/product-builder.ts" badgeLabel="Storefront" badgeColor="blue" +import { LineItemWithBuilderMetadata } from "../../types/global" +``` + +Then, add the following function at the end of the file: + +```ts title="src/lib/util/product-builder.ts" badgeLabel="Storefront" badgeColor="blue" +export function isBuilderLineItem(lineItem: LineItemWithBuilderMetadata): boolean { + return lineItem?.metadata?.is_builder_main_product === true +} +``` + +You'll use this function in the next customizations. + +#### Add Builder Product to Cart Function + +In this section, you'll add a server function that sends a request to the API route you created earlier. You'll use this function when adding products with builder configurations to the cart. + +In `src/lib/data/cart.ts`, add the following imports at the top of the file: + +```ts title="src/lib/data/cart.ts" badgeLabel="Storefront" badgeColor="blue" +import { BuilderConfiguration, LineItemWithBuilderMetadata } from "../../types/global" +import { isBuilderLineItem } from "../util/product-builder" +``` + +Then, add the following function to the file: + +```ts title="src/lib/data/cart.ts" badgeLabel="Storefront" badgeColor="blue" +export async function addBuilderProductToCart({ + productId, + variantId, + quantity, + countryCode, + builderConfiguration, +}: { + productId: string + variantId: string + quantity: number + countryCode: string + builderConfiguration?: BuilderConfiguration +}) { + if (!variantId) { + throw new Error("Missing variant ID when adding to cart") + } + + const cart = await getOrSetCart(countryCode) + + if (!cart) { + throw new Error("Error retrieving or creating cart") + } + + // If no builder configuration, use regular addToCart + if (!builderConfiguration) { + return addToCart({ variantId, quantity, countryCode }) + } + + const headers = { + ...(await getAuthHeaders()), + } + + await sdk.client.fetch(`/store/carts/${cart.id}/product-builder`, { + method: "POST", + headers, + body: { + product_id: productId, + variant_id: variantId, + quantity, + custom_field_values: builderConfiguration.custom_fields.reduce( + (acc, field) => { + acc[field.field_id] = field.value + return acc + }, + {} as Record + ), + complementary_product_variants: builderConfiguration.complementary_products.map( + (comp) => comp.variant_id + ), + addon_variants: builderConfiguration.addons.map((addon) => addon.variant_id), + }, + }) + .then(async () => { + const cartCacheTag = await getCacheTag("carts") + revalidateTag(cartCacheTag) + + const fulfillmentCacheTag = await getCacheTag("fulfillment") + revalidateTag(fulfillmentCacheTag) + }) + .catch(medusaError) +} +``` + +This function adds a product with a builder configuration to the cart by sending a request to the API route you created earlier. If no builder configuration is provided, it falls back to the regular `addToCart` function (which is defined in the same file). + +#### Use Add Builder Product to Cart Function + +Finally, you'll use the `addBuilderProductToCart` function in the `ProductActions` component, where the add-to-cart button is located. + +In `src/modules/products/components/product-actions/index.tsx`, add the following imports at the top of the file: + +```tsx title="src/modules/products/components/product-actions/index.tsx" badgeLabel="Storefront" badgeColor="blue" +import { + addBuilderProductToCart, +} from "@lib/data/cart" +import { + toast, +} from "@medusajs/ui" +``` + +Then, in the `ProductActions` component, find the `handleAddToCart` function and replace it with the following: + +```tsx title="src/modules/products/components/product-actions/index.tsx" badgeLabel="Storefront" badgeColor="blue" +const handleAddToCart = async () => { + if (!selectedVariant?.id) {return null} + + setIsAdding(true) + + try { + // Check if product has builder configuration + if (hasProductBuilder(product) && builderConfig) { + await addBuilderProductToCart({ + productId: product.id!, + variantId: selectedVariant.id, + quantity: 1, + countryCode, + builderConfiguration: builderConfig, + }) + } else { + // Use regular addToCart for products without builder configuration + await addToCart({ + variantId: selectedVariant.id, + quantity: 1, + countryCode, + }) + } + } catch (error) { + toast.error(`Failed to add product to cart: ${error}`) + } finally { + setIsAdding(false) + } +} +``` + +If the product has builder configurations, you call the `addBuilderProductToCart` function. Otherwise, you fall back to the regular `addToCart` function. + +#### Test Add to Cart Functionality + +To test out the add-to-cart functionality with builder configurations, make sure that both the Medusa application and the Next.js Starter Storefront are running. + +Then, open the page of a product with builder configurations in the storefront. Select the configurations, and add them to the cart. + +The cart will be updated with the main product and selected complementary and addon product variants. + +![Cart dropdown showing the product with its complementary product](https://res.cloudinary.com/dza7lstvk/image/upload/v1755160166/Medusa%20Resources/CleanShot_2025-08-14_at_11.28.53_2x_bcskgr.png) + +### b. Customize Cart Page + +Next, you'll customize the cart page to display the custom field values of a product, and group addon products with the main product. + +You'll start with some styling changes, then update the cart item rendering logic to include the custom fields and addon products. + +![Screenshot showcasing which areas of the cart page will be updated and how they'll look like](https://res.cloudinary.com/dza7lstvk/image/upload/v1755160329/Medusa%20Resources/CleanShot_2025-08-14_at_11.31.50_2x_tpxlht.png) + +#### Styling Changes + +You'll first update the style of the quantity changer component for a better design. + +![Screenshot showcasing the updated quantity changer component](https://res.cloudinary.com/dza7lstvk/image/upload/v1755160526/Medusa%20Resources/CleanShot_2025-08-14_at_11.35.07_2x_vtmnuz.png) + +In `src/modules/cart/components/cart-item-select/index.tsx`, replace the file content with the following: + +export const cartItemSelectHighlights = [ + ["18", "max", "Pass new prop."], + ["19", "onQuantityChange", "Pass new prop."], + ["25", "value", "Store input value."], + ["32", "onMinus", "Decrement quantity."], + ["38", "onPlus", "Increment quantity."], + ["49", "useEffect", "Call onQuantityChange when value changes."], + ["59", "input", "Use input instead of `select`."], +] + +```tsx title="src/modules/cart/components/cart-item-select/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={cartItemSelectHighlights} +"use client" + +import { IconButton, clx } from "@medusajs/ui" +import { + SelectHTMLAttributes, + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react" +import { Minus, Plus } from "@medusajs/icons" + +type NativeSelectProps = { + placeholder?: string + errors?: Record + touched?: Record + max?: number + onQuantityChange?: (quantity: number) => void +} & SelectHTMLAttributes + +const CartItemSelect = forwardRef( + ({ className, children, value: initialValue, onQuantityChange, ...props }, ref) => { + const innerRef = useRef(null) + const [value, setValue] = useState(initialValue as number || 1) + + useImperativeHandle( + ref, + () => innerRef.current + ) + + const onMinus = () => { + setValue((prevValue) => { + return prevValue > 1 ? prevValue - 1 : 1 + }) + } + + const onPlus = () => { + setValue((prevValue) => { + return Math.min(prevValue + 1, props.max || Infinity) + }) + } + + const handleChange = (event: React.ChangeEvent) => { + setValue(Math.min(parseInt(event.target.value) || 1, props.max || Infinity)) + onQuantityChange?.(value) + } + + useEffect(() => { + onQuantityChange?.(value) + }, [value]) + + return ( +
+ + + + + + + +
+ ) + } +) + +CartItemSelect.displayName = "CartItemSelect" + +export default CartItemSelect +``` + +You make the following key changes: + +- Pass the `max` and `onQuantityChange` props to the `CartItemSelect` component. +- Use a `value` state variable to manage the input value. +- Add `+` and `-` buttons to increase or decrease the quantity. +- Call the `onQuantityChange` prop whenever the quantity changes. +- Show an input field rather than a select field for quantity. + +Next, you'll update the styling of the delete button that removes items from the cart. + +![Screenshot showcasing the updated delete button](https://res.cloudinary.com/dza7lstvk/image/upload/v1755160603/Medusa%20Resources/CleanShot_2025-08-14_at_11.36.29_2x_myhwyf.png) + +In `src/modules/common/components/delete-button/index.tsx`, add the following import at the top of the file: + +```tsx title="src/modules/common/components/delete-button/index.tsx" badgeLabel="Storefront" badgeColor="blue" +import { IconButton } from "@medusajs/ui" +``` + +Then, in the `DeleteButton` component, replace the `button` element in the `return` statement with the following: + +```tsx title="src/modules/common/components/delete-button/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["6"], ["7"], ["8"], ["9"]]} +return ( +
+ {/* ... */} + handleDelete(id)}> + {isDeleting ? : } + {children} + +
+) +``` + +Next, you'll update the `LineItemOptions` component to receive a `className` prop that allows customizing its styles. + +In `src/modules/common/components/line-item-options/index.tsx`, add the following import at the top of the file: + +```tsx title="src/modules/common/components/line-item-options/index.tsx" badgeLabel="Storefront" badgeColor="blue" +import { clx } from "@medusajs/ui" +``` + +Next, update the `LineItemOptionsProps` to accept a `className` prop: + +```tsx title="src/modules/common/components/line-item-options/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["3"]]} +type LineItemOptionsProps = { + // ... + className?: string +} +``` + +Then, destructure the `className` prop and use it in the `return` statement of the `LineItemOptions` component: + +```tsx title="src/modules/common/components/line-item-options/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["3"], ["11"]]} +const LineItemOptions = ({ + // ... + className, +}: LineItemOptionsProps) => { + return ( + + Variant: {variant?.title} + + ) +} +``` + +Next, you'll make small adjustments to the text size of the item price components. + +![Screenshot showcasing the updated item price components](https://res.cloudinary.com/dza7lstvk/image/upload/v1755160898/Medusa%20Resources/CleanShot_2025-08-14_at_11.41.23_2x_eitu29.png) + +In `src/modules/common/components/line-item-price/index.tsx`, update the `className` prop of the `span` element containing the price: + +```tsx title="src/modules/common/components/line-item-price/index.tsx" highlights={[["5"]]} badgeLabel="Storefront" badgeColor="blue" +return ( +
+ {/* ... */} + + {convertToLocale({ + amount: currentPrice, + currency_code: currencyCode, + })} + + {/* ... */} +
+) +``` + +You change the `text-base-regular` class to `txt-small`. + +Next, in `src/modules/common/components/line-item-unit-price/index.tsx`, update the `className` prop of the wrapper `div` and the `span` element containing the price: + +```tsx title="src/modules/common/components/line-item-unit-price/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["2"], ["5"]]} +return ( +
+ {/* ... */} + + {convertToLocale({ + amount: total / item.quantity, + currency_code: currencyCode, + })} + + {/* ... */} +
+) +``` + +You changed the `text-ui-fg-muted` class in the wrapper `div` to `text-ui-fg-subtle`, and the `text-base-regular` class in the `span` element to `txt-small`. + +#### Update Item Component + +Next, you'll update the component showing a line item row. This component is used in mutliple places, including the cart and checkout pages. + +You'll update the component to ignore addon products. Instead, you'll show them as part of the main product line item. You'll also display the custom field values of the main product. + +In `src/modules/cart/components/item/index.tsx`, add the following import at the top of the file: + +```tsx title="src/modules/cart/components/item/index.tsx" badgeLabel="Storefront" badgeColor="blue" +import { LineItemWithBuilderMetadata } from "../../../../types/global" +import { isBuilderLineItem } from "../../../../lib/util/product-builder" +``` + +Then, add a `cartItems` prop to the `ItemProps`: + +```tsx title="src/modules/cart/components/item/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["3"]]} +type ItemProps = { + // ... + cartItems?: HttpTypes.StoreCartLineItem[] +} +``` + +And add the prop to the `Item` component's destructured props: + +```tsx title="src/modules/cart/components/item/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["1", "cartItems"]]} +const Item = ({ item, type = "full", currencyCode, cartItems }: ItemProps) => { + // ... +} +``` + +Next, add the following in the component before the `return` statement: + +```tsx title="src/modules/cart/components/item/index.tsx" badgeLabel="Storefront" badgeColor="blue" +const Item = ({ item, type = "full", currencyCode, cartItems }: ItemProps) => { + // ... + // Check if this is a main product builder item + const itemWithMetadata = item as LineItemWithBuilderMetadata + const isMainBuilderProduct = isBuilderLineItem(itemWithMetadata) + + // Find addon items for this main product + const addonItems = isMainBuilderProduct && cartItems + ? cartItems.filter((cartItem: any) => + cartItem.metadata?.main_product_line_item_id === item.id && + cartItem.metadata?.is_addon === true + ) + : [] + + // Don't render addon items as separate rows (they'll be shown under the main item) + if (itemWithMetadata.metadata?.is_addon === true) { + return null + } + + // ... +} +``` + +You create an `itemWithMetadata` variable, which is a typed version of the `item` prop that includes the metadata fields you defined earlier. + +Next, if the item being viewed is a main product with builder configurations, you retrieve its addon items. Otherwise, if it's an addon item, you return `null` to skip rendering it as a separate row. + +Finally, update the `return` statement to the following: + +export const itemComponentHighlights = [ + ["31", "", "Render custom field values."], + ["47", "onQuantityChange", "Pass new prop."], + ["54", "max", "Pass new prop."], + ["99", "", "Render the addon items."] +] + +```tsx title="src/modules/cart/components/item/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={itemComponentHighlights} +return ( + <> + 0 ? "border-b-0": "" + )} data-testid="product-row"> + + + + + + + + + {item.product_title} + + + {!!itemWithMetadata.metadata?.custom_fields && ( +
+ {itemWithMetadata.metadata.custom_fields.map((field) => ( + + {field.name}: {field.value} + + ))} +
+ )} +
+ + {type === "full" && ( + +
+ { + if (value === item.quantity) { + return + } + changeQuantity(value) + }} + data-testid="product-select-button" + max={maxQuantity} + /> + + {updating && } +
+ +
+ )} + + {type === "full" && ( + + + + )} + + + + {type === "preview" && ( + + {item.quantity}x + + + )} + + + +
+ + {/* Display addon items if this is a main builder product */} + {isMainBuilderProduct && addonItems.length > 0 && addonItems.map((addon: any) => ( + + + + +
+
+ + {addon.product_title} + +
+ +
+
+
+
+ + {type === "full" && ( + + + + )} + + {type === "full" && ( + + + + )} + + + + + + +
+ ))} + +) +``` + +You make the following key changes: + +- Render the custom field values. +- Pass the new props to the `CartItemSelect` component. +- Render the add-on items after the main product item. +- Make general styling updates to improve the layout. + +You also need to update the components that use the `Item` component to pass the new `cartItems` prop. + +In `src/modules/cart/templates/items.tsx`, replace the `return` statement with the following: + +export const itemsTemplateHighlights = [ + ["13", "className"], + ["17", "className"], + ["34", "cartItems", "Pass new prop"] +] + +```tsx title="src/modules/cart/templates/items.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={itemsTemplateHighlights} +const ItemsTemplate = ({ cart }: ItemsTemplateProps) => { + // ... + return ( +
+
+ Cart +
+ + + + Item + + Quantity + + Price + + + Total + + + + + {items + ? items + .sort((a, b) => { + return (a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1 + }) + .map((item) => { + return ( + + ) + }) + : repeat(5).map((i) => { + return + })} + +
+
+ ) +} +``` + +You pass the `cartItems` prop to the `Item` component, and you pass new class names to other components for better styling. + +Finally, in `src/modules/cart/templates/preview.tsx`, find the `Item` component in the `return` statement and update it to pass the `cartItems` prop: + +```tsx title="src/modules/cart/templates/preview.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["13", "cartItems", "Pass new prop."]]} +const ItemsPreviewTemplate = ({ cart }: ItemsTemplateProps) => { + // ... + return ( +
+ {/* ... */} + + {/* ... */} +
+ ) +} +``` + +#### Test out Changes + +To test out the design changes to the cart page, make sure both the Medusa application and the Next.js Starter Storefront are running. + +Then, open the cart page in the storefront. If you have a product with an addon and custom fields in the cart, you'll see them displayed within the main product's row. + +![Screenshot showcasing the updated cart page with custom fields and addon products](https://res.cloudinary.com/dza7lstvk/image/upload/v1755161965/Medusa%20Resources/CleanShot_2025-08-14_at_11.59.16_2x_pfo20r.png) + +### c. Update Cart Items Count in Dropdown + +The cart dropdown at the top right of the page will display the total number of items in the cart, including addon products. + +You'll update the cart dropdown to ignore the quantity of addon products when displaying the total count. + +In `src/modules/layout/components/cart-dropdown/index.tsx`, add the following variable before the `totalItems` variable in the `CartDropdown` component: + +```tsx title="src/modules/layout/components/cart-dropdown/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["7"]]} +const CartDropdown = ({ + cart: cartState, +}: { + cart?: HttpTypes.StoreCart | null +}) => { + // ... + const filteredItems = cartState?.items?.filter((item) => !item.metadata?.is_addon) + // ... +} +``` + +You filter the items to exclude any that are marked as add-ons. + +Next, replace the `totalItems` declaration with the following: + +```tsx title="src/modules/layout/components/cart-dropdown/index.tsx" badgeLabel="Storefront" badgeColor="blue" +const CartDropdown = ({ + cart: cartState, +}: { + cart?: HttpTypes.StoreCart | null +}) => { + // ... + const totalItems = + filteredItems?.reduce((acc, item) => { + return acc + item.quantity + }, 0) || 0 + // ... +} +``` + +You calculate the total items by summing the quantities of the `filteredItems`, which excludes addon products. + +Finally, in the `return` statement, replace all usages of `cartState.items` with `filteredItems`, and remove the children element of the `DeleteButton` for better styling: + +export const cartDropdownHighlights = [ + ["6", "filteredItems"], ["9", "filteredItems"], + ["21"], ["22"], ["23"], ["24"], ["25"] +] + +```tsx title="src/modules/layout/components/cart-dropdown/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={cartDropdownHighlights} +return ( +
+ {/* ... */} + {cartState && filteredItems?.length ? ( + <> +
+ {filteredItems + .sort((a, b) => { + return (a.created_at ?? "") > (b.created_at ?? "") + ? -1 + : 1 + }) + .map((item) => ( + // .. +
+ {/* ... */} + + {/* ... */} +
+ )) + } +
+ {/* ... */} + + ) : ( + {/* ... */} + )} + {/* ... */} +
+) +``` + +#### Test Cart Dropdown Changes + +To test out the cart dropdown changes, make sure both the Medusa application and the Next.js Starter Storefront are running. + +Then, check the "Cart" navigation item at the top right. The total count next to "Cart" should not include the addon products, and the dropdown should exclude them as well. + +![Updated cart dropdown with updated total count and filtered items](https://res.cloudinary.com/dza7lstvk/image/upload/v1755160166/Medusa%20Resources/CleanShot_2025-08-14_at_11.28.53_2x_bcskgr.png) + +--- + +## Step 10: Delete Product with Builder Configurations from Cart + +In this step, you'll implement the logic to delete a product with builder configurations from the cart. This will include removing its addon products from the cart. + +You'll create a workflow, use that workflow in an API route, then customize the storefront to use this API route when deleting a product with builder configurations from the cart. + +### a. Remove Product with Builder Configurations from Cart Workflow + +The workflow to remove a product with builder configurations from the cart has the following steps: + + + +Medusa provides all of these steps, so you can create the workflow without needing to implement any custom steps. + +Create the file `src/workflows/remove-product-builder-from-cart.ts` with the following content: + +export const removeProductBuilderFromCartWorkflowHighlights = [ + ["17", "carts", "Retrieve cart."], + ["29", "itemsToRemove", "Identify items to remove."], + ["43", "relatedItems", "Identify addon items to remove."], + ["60", "deleteLineItemsWorkflow", "Delete line items from cart."], + ["65", "updatedCart", "Retrieve updated cart."] +] + +```ts title="src/workflows/remove-product-builder-from-cart.ts" highlights={removeProductBuilderFromCartWorkflowHighlights} +import { + createWorkflow, + WorkflowResponse, + transform, +} from "@medusajs/framework/workflows-sdk" +import { deleteLineItemsWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows" + +type RemoveProductBuilderFromCartInput = { + cart_id: string + line_item_id: string +} + +export const removeProductBuilderFromCartWorkflow = createWorkflow( + "remove-product-builder-from-cart", + (input: RemoveProductBuilderFromCartInput) => { + // Step 1: Get current cart with all items + const { data: carts } = useQueryGraphStep({ + entity: "cart", + fields: ["*", "items.*", "items.metadata"], + filters: { + id: input.cart_id, + }, + options: { + throwIfKeyNotFound: true, + }, + }) + + // Step 2: Remove line item and its addons + const itemsToRemove = transform({ + input, + carts, + }, (data) => { + const cart = data.carts[0] + const targetLineItem = cart.items.find( + (item: any) => item.id === data.input.line_item_id + ) + const lineItemIdsToRemove = [data.input.line_item_id] + const isBuilderItem = + targetLineItem?.metadata?.is_builder_main_product === true + + if (targetLineItem && isBuilderItem) { + // Find all related addon items + const relatedItems = cart.items.filter((item: any) => + item.metadata?.main_product_line_item_id === data.input.line_item_id && + item.metadata?.is_addon === true + ) + + // Add their IDs to the removal list + lineItemIdsToRemove.push( + ...relatedItems.map((item: any) => item.id) + ) + } + + return { + cart_id: data.input.cart_id, + ids: lineItemIdsToRemove, + } + }) + + deleteLineItemsWorkflow.runAsStep({ + input: itemsToRemove, + }) + + // Step 3: Get the updated cart + const { data: updatedCart } = useQueryGraphStep({ + entity: "cart", + fields: ["*", "items.*", "items.metadata"], + filters: { + id: input.cart_id, + }, + options: { + throwIfKeyNotFound: true, + }, + }).config({ name: "get-updated-cart" }) + + return new WorkflowResponse({ + cart: updatedCart[0], + }) + } +) +``` + +This workflow receives the IDs of the cart and the line item to remove. + +In the workflow, you: + +- Retrieve the cart details with its items. +- Prepare the line items to remove by identifying the main product and its related addons. +- Remove the line items from the cart. +- Retrieve the updated cart details. + +You return the cart details in the response. + +### b. Remove Product with Builder Configurations from Cart API Route + +Next, you'll create an API route that uses the workflow you created to remove a product with builder configurations from the cart. + +Create the file `src/api/store/carts/[id]/product-builder/[item_id]/route.ts` with the following content: + +```ts title="src/api/store/carts/[id]/product-builder/[item_id]/route.ts" +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { removeProductBuilderFromCartWorkflow } from "../../../../../../workflows/remove-product-builder-from-cart" + +export const DELETE = async ( + req: MedusaRequest, + res: MedusaResponse +) => { + const { + id: cartId, + item_id: lineItemId, + } = req.params + + const { result } = await removeProductBuilderFromCartWorkflow(req.scope) + .run({ + input: { + cart_id: cartId, + line_item_id: lineItemId, + }, + }) + + res.json({ + cart: result.cart, + }) +} +``` + +You expose a `DELETE` API route at `/store/carts/[id]/product-builder/[item_id]`. + +In the route handler, you execute the `removeProductBuilderFromCartWorkflow` with the cart and line item IDs from the request path parameters. + +You return the cart details in the response. + +### c. Use API Route in Storefront + +Next, you'll customize the storefront to use the API route you created when deleting a product with builder configurations from the cart. + +In `src/lib/data/cart.ts`, add the following function: + +```ts title="src/lib/data/cart.ts" badgeLabel="Storefront" badgeColor="blue" +export async function removeBuilderLineItem(lineItemId: string) { + if (!lineItemId) { + throw new Error("Missing lineItem ID when deleting builder line item") + } + + const cartId = await getCartId() + + if (!cartId) { + throw new Error("Missing cart ID when deleting builder line item") + } + + const headers = { + ...(await getAuthHeaders()), + } + + await sdk.client + .fetch(`/store/carts/${cartId}/product-builder/${lineItemId}`, { + method: "DELETE", + headers, + }) + .then(async () => { + const cartCacheTag = await getCacheTag("carts") + revalidateTag(cartCacheTag) + + const fulfillmentCacheTag = await getCacheTag("fulfillment") + revalidateTag(fulfillmentCacheTag) + }) + .catch(medusaError) +} +``` + +This function sends a `DELETE` request to the API route you created earlier to remove a line item with builder configurations from the cart. + +Next, to use this function when deleting a line item from the cart, you'll update the `DeleteButton` component to accept a new prop that determines whether the item belongs to a product with builder configurations. + +In `src/modules/common/components/delete-button/index.tsx`, add the following import at the top of the file: + +```tsx title="src/modules/common/components/delete-button/index.tsx" badgeLabel="Storefront" badgeColor="blue" +import { removeBuilderLineItem } from "@lib/data/cart" +``` + +Then, pass an `isBuilderConfigItem` prop to the `DeleteButton` component, and update its `handleDelete` function to use it: + +export const deleteButtonHighlights = [ + ["3", "isBuilderConfigItem", "New prop to determine if item belongs to a product with builder configurations."], + ["6"], + ["12", "removeBuilderLineItem", "Use when the line item belongs to a product with builder configurations."], +] + +```tsx title="src/modules/common/components/delete-button/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={deleteButtonHighlights} +const DeleteButton = ({ + // ... + isBuilderConfigItem = false, +}: { + // ... + isBuilderConfigItem?: boolean +}) => { + // ... + const handleDelete = async (id: string) => { + setIsDeleting(true) + if (isBuilderConfigItem) { + await removeBuilderLineItem(id).catch((err) => { + setIsDeleting(false) + }) + } else { + await deleteLineItem(id).catch((err) => { + setIsDeleting(false) + }) + } + } + + // ... +} +``` + +You update the `handleDelete` function to use the `removeBuilderLineItem` function if the `isBuilderConfigItem` prop is `true`. Otherwise, it uses the regular `deleteLineItem` function. + +Next, you need to pass the `isBuilderConfigItem` prop to the `DeleteButton` component in the components using it. + +In `src/modules/cart/components/item/index.tsx`, update the first `DeleteButton` component usage in the return statement to pass the `isBuilderConfigItem` prop: + +```tsx title="src/modules/cart/components/item/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["7", "isBuilderConfigItem", "Pass new prop"]]} +return ( + <> + {/* ... */} + + {/* ... */} + +) +``` + +Don't update the `DeleteButton` for addon products, as they don't have builder configurations. + +Then, in `src/modules/layout/components/cart-dropdown/index.tsx`, add the following import at the top of the file: + +```tsx title="src/modules/layout/components/cart-dropdown/index.tsx" badgeLabel="Storefront" badgeColor="blue" +import { LineItemWithBuilderMetadata } from "../../../../types/global" +import { isBuilderLineItem } from "../../../../lib/util/product-builder" +``` + +Next, find the `DeleteButton` usage in the `return` statement and update it to pass the `isBuilderConfigItem` prop: + +```tsx title="src/modules/layout/components/cart-dropdown/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["10", "isBuilderConfigItem", "Pass new prop"]]} +return ( +
+ {/* ... */} + + {/* ... */} +
+) +``` + +### Test Deleting Product with Builder Configurations from Cart + +To test out the changes, make sure both the Medusa application and the Next.js Starter Storefront are running. + +Then, in the storefront, delete the product with builder configurations from the cart either from the cart page or the cart dropdown. The addon item will also be removed from the cart. + +--- + +## Step 11: Show Product Builder Configurations in Order Confirmation + +In this step, you'll customize the order confirmation page in the storefront to group addon products with their main product, similar to the cart page. + +In `src/modules/order/components/item/index.tsx`, add the following import at the top of the file: + +```tsx title="src/modules/order/components/item/index.tsx" badgeLabel="Storefront" badgeColor="blue" +import { clx } from "@medusajs/ui" +import { LineItemWithBuilderMetadata } from "../../../../types/global" +import { isBuilderLineItem } from "../../../../lib/util/product-builder" +``` + +Next, pass a new `orderItems` prop to the `Item` component and its prop type: + +export const orderItemHighlights = [ + ["3", "orderItems", "Pass new prop."], + ["6", "orderItems"] +] + +```tsx title="src/modules/order/components/item/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={orderItemHighlights} +type ItemProps = { + // ... + orderItems?: (HttpTypes.StoreCartLineItem | HttpTypes.StoreOrderLineItem)[] +} + +const Item = ({ item, currencyCode, orderItems }: ItemProps) => { + // ... +} +``` + +This prop will include all the items in the order, allowing you to find the addons of a main product. + +After that, add the following in the `Item` component before the `return` statement: + +export const orderItemLogicHighlights = [ + ["4", "isMainBuilderProduct", "Check if item has builder config."], + ["7", "addonItems", "Find addon items for this product."], + ["9", "main_product_line_item_id", "Match the addon item's `main_product_line_item_id` to the order item's `cart_line_item_id` metadata."], + ["15", "is_addon === true", "Skip rendering addon items separately."] +] + +```tsx title="src/modules/order/components/item/index.tsx" highlights={orderItemLogicHighlights} badgeLabel="Storefront" badgeColor="blue" +const Item = ({ item, currencyCode, orderItems }: ItemProps) => { + // Check if this is a main product builder item + const itemWithMetadata = item as LineItemWithBuilderMetadata + const isMainBuilderProduct = isBuilderLineItem(itemWithMetadata) + + // Find addon items for this main product + const addonItems = isMainBuilderProduct && orderItems + ? orderItems.filter((orderItem: any) => + orderItem.metadata?.main_product_line_item_id === item.metadata?.cart_line_item_id && + orderItem.metadata?.is_addon === true + ) + : [] + + // Don't render addon items as separate rows (they'll be shown under the main item) + if (itemWithMetadata.metadata?.is_addon === true) { + return null + } + + // ... +} +``` + +If the item is a main product, you retrieve its addons. If an item is an addon, you return `null` to skip rendering it as a separate row. + +Finally, replace the `return` statement with the following: + +export const orderItemReturnsHighlights = [ + ["21", "", "Show custom field values of item."], + ["55", "", "Show addon items grouped with the main item."] +] + +```tsx title="src/modules/order/components/item/index.tsx" highlights={orderItemReturnsHighlights} badgeLabel="Storefront" badgeColor="blue" +return ( + <> + 0 ? "border-b-0": "" + )} data-testid="product-row"> + +
+ +
+
+ + + + {item.product_title} + + + {!!itemWithMetadata.metadata?.custom_fields && ( +
+ {itemWithMetadata.metadata.custom_fields.map((field) => ( + + {field.name}: {field.value} + + ))} +
+ )} +
+ + + + + + {item.quantity}x{" "} + + + + + + + +
+ + {/* Display addon items if this is a main builder product */} + {isMainBuilderProduct && addonItems.length > 0 && addonItems.map((addon: any) => ( + + + + +
+
+ + {addon.product_title} + +
+ +
+
+
+
+ + + + + + {addon.quantity}x{" "} + + + + + + + +
+ ))} + +) +``` + +You make the following key changes: + +- Show the custom field values of a product with builder configurations. +- Show addons as a row after the main product row. +- Other design and styling changes. + +You need to pass the `orderItems` prop to the `Item` component in the components using it. + +In `src/modules/order/components/items/index.tsx`, find the `Item` component in the return statement and add the `orderItems` prop: + +```tsx title="src/modules/order/components/items/index.tsx" badgeLabel="Storefront" badgeColor="blue" highlights={[["10", "orderItems", "Pass new prop"]]} +const Items = ({ order }: ItemsProps) => { + // ... + return ( +
+ {/* ... */} + + {/* ... */} +
+ ) +} +``` + +### Test Order Confirmation Page + +To test out the changes in the order confirmation page, make sure both the Medusa application and the Next.js Starter Storefront are running. + +Then, place an order with a product that has builder configurations. In the confirmation page, you'll see the product with its custom fields and addon items displayed similar to the cart page. + +![Updated order confirmation page](https://res.cloudinary.com/dza7lstvk/image/upload/v1755168706/Medusa%20Resources/CleanShot_2025-08-14_at_13.51.33_2x_bdadlh.png) + +--- + +## Step 12: Show Product Builder Configuration in Order Admin Page + +In the last step, you'll inject an admin widget to the order details page that shows the product builder configurations for each item in the order. + +To create the widget, create the file `src/admin/widgets/order-builder-details-widget.tsx` with the following content: + +export const orderWidgetHighlights = [ + ["32", "builderItems", "Find items that have builder configurations."], + ["42", "getAddonItems", "Find all addon items for an item with builder configurations."], +] + +```tsx title="src/admin/widgets/order-builder-details-widget.tsx" highlights={orderWidgetHighlights} +import { defineWidgetConfig } from "@medusajs/admin-sdk" +import { Container, Heading, Text, clx } from "@medusajs/ui" +import { DetailWidgetProps, AdminOrder } from "@medusajs/framework/types" + +type BuilderLineItemMetadata = { + is_builder_main_product?: boolean + main_product_line_item_id?: string + product_builder_id?: string + custom_fields?: { + field_id: string + name?: string + value: string + }[] + is_addon?: boolean + cart_line_item_id?: string +} + +type LineItemWithBuilderMetadata = { + id: string + product_title: string + variant_title?: string + quantity: number + metadata?: BuilderLineItemMetadata +} + +const OrderBuilderDetailsWidget = ({ + data: order, +}: DetailWidgetProps) => { + const orderItems = (order.items || []) as LineItemWithBuilderMetadata[] + + // Find all builder main products (items with custom configurations) + const builderItems = orderItems.filter((item) => + item.metadata?.is_builder_main_product || + item.metadata?.custom_fields?.length + ) + + // If no builder items, don't show the widget + if (builderItems.length === 0) { + return null + } + + const getAddonItems = (mainItemId: string) => { + return orderItems.filter((item) => + item.metadata?.main_product_line_item_id === mainItemId && + item.metadata?.is_addon === true + ) + } + + return ( + +
+ Items with Builder Configurations +
+ +
+ {builderItems.map((item, index) => { + const addonItems = getAddonItems(item.metadata?.cart_line_item_id || "") + const isLastItem = index === builderItems.length - 1 + + return ( +
+ {/* Main Product Info */} +
+
+ + {item.product_title} + + {item.variant_title && ( + + Variant: {item.variant_title} + + )} + + Quantity: {item.quantity} + +
+
+ + {/* Custom Fields */} + {item.metadata?.custom_fields && item.metadata.custom_fields.length > 0 && ( +
+ + Custom Fields + +
+ {item.metadata.custom_fields.map((field, index) => ( +
+ + {field.name || `Field ${index + 1}`} + + + {field.value} + +
+ ))} +
+
+ )} + + {/* Addon Products */} + {addonItems.length > 0 && ( +
+ + Add-on Products ({addonItems.length}) + +
+ {addonItems.map((addon) => ( +
+
+ + {addon.product_title} + + {addon.variant_title && ( + + Variant: {addon.variant_title} + + )} + + Quantity: {addon.quantity} + +
+
+ ))} +
+
+ )} +
+ ) + })} +
+
+ ) +} + +export const config = defineWidgetConfig({ + zone: "order.details.side.after", +}) + +export default OrderBuilderDetailsWidget +``` + +You first define types for the line item of a product builder, and a type for its metadata. + +Then, in the widget, you find the items that have builder configurations by checking if they have the `is_builder_main_product` metadata or custom fields. + +If no builder items are found, the widget will not be displayed. Otherwise, you display the item's custom values and add-on products. + +Notice that to find the addons of the main product, you compare the `main_product_line_item_id` of the addon with the `cart_line_item_id` of the main product's item. + +### Test Order Admin Widget + +To test out the widget on the order details page: + +1. Make sure the Medusa Application is running. +2. Open the Medusa Admin dashboard and log in. +3. Go to Orders. +4. Click on an order that contains an item with builder configurations. + +You'll find at the end of the side section an "Items with Builder Configurations" section. The section will show the custom field values and add-ons for each item that has builder configurations. + +![Widget on the order details page showing the builder configurations for each item](https://res.cloudinary.com/dza7lstvk/image/upload/v1755169611/Medusa%20Resources/CleanShot_2025-08-14_at_14.06.40_2x_om7f6e.png) + +--- + +## Next Steps + +You've now implemented the product builder feature in Medusa. You can expand on this feature based on your use case. You can: + +- Allow users to edit their product configurations from the cart or checkout page. +- Disallow purchasing addon products without a main product by filtering products with the `addon` tag. +- Expand on the builder configurations to support more complex setups. + +### Learn More about Medusa + +If you're new to Medusa, check out the [main documentation](!docs!/learn), where you'll get a more in-depth understanding of all the concepts you've used in this guide and more. + +To learn more about the commerce features that Medusa provides, check out Medusa's [Commerce Modules](../../../commerce-modules/page.mdx). + +### Troubleshooting + +If you encounter issues during your development, check out the [troubleshooting guides](../../../troubleshooting/page.mdx). + +### Getting Help + +If you encounter issues not covered in the troubleshooting guides: + +1. Visit the [Medusa GitHub repository](https://github.com/medusajs/medusa) to report issues or ask questions. +2. Join the [Medusa Discord community](https://discord.gg/medusajs) for real-time support from community members. diff --git a/www/apps/resources/generated/edit-dates.mjs b/www/apps/resources/generated/edit-dates.mjs index 458499217c..c122fabf22 100644 --- a/www/apps/resources/generated/edit-dates.mjs +++ b/www/apps/resources/generated/edit-dates.mjs @@ -6559,13 +6559,14 @@ export const generatedEditDates = { "app/commerce-modules/promotion/promotion-taxes/page.mdx": "2025-06-27T15:44:46.638Z", "app/troubleshooting/payment/page.mdx": "2025-07-16T10:20:24.799Z", "app/recipes/personalized-products/example/page.mdx": "2025-07-22T08:53:58.182Z", - "app/how-to-tutorials/tutorials/preorder/page.mdx": "2025-07-18T06:57:19.943Z", + "app/how-to-tutorials/tutorials/preorder/page.mdx": "2025-08-13T12:09:06.154Z", "references/js_sdk/admin/Order/methods/js_sdk.admin.Order.archive/page.mdx": "2025-07-24T08:20:57.709Z", "references/js_sdk/admin/Order/methods/js_sdk.admin.Order.complete/page.mdx": "2025-07-24T08:20:57.714Z", "app/commerce-modules/cart/cart-totals/page.mdx": "2025-07-31T15:18:13.978Z", "app/commerce-modules/order/order-totals/page.mdx": "2025-07-31T15:12:10.633Z", "app/commerce-modules/user/invite-user-subscriber/page.mdx": "2025-08-01T12:01:54.551Z", "app/how-to-tutorials/tutorials/invoice-generator/page.mdx": "2025-08-04T00:00:00.000Z", + "app/how-to-tutorials/tutorials/product-builder/page.mdx": "2025-08-14T11:21:18.409Z", "app/integrations/guides/payload/page.mdx": "2025-08-21T05:24:11.537Z", "references/js_sdk/admin/Client/methods/js_sdk.admin.Client.getToken/page.mdx": "2025-08-14T12:59:55.678Z" } \ No newline at end of file diff --git a/www/apps/resources/generated/files-map.mjs b/www/apps/resources/generated/files-map.mjs index e5319e2cab..65e53763cd 100644 --- a/www/apps/resources/generated/files-map.mjs +++ b/www/apps/resources/generated/files-map.mjs @@ -779,6 +779,10 @@ export const filesMap = [ "filePath": "/www/apps/resources/app/how-to-tutorials/tutorials/preorder/page.mdx", "pathname": "/how-to-tutorials/tutorials/preorder" }, + { + "filePath": "/www/apps/resources/app/how-to-tutorials/tutorials/product-builder/page.mdx", + "pathname": "/how-to-tutorials/tutorials/product-builder" + }, { "filePath": "/www/apps/resources/app/how-to-tutorials/tutorials/product-reviews/page.mdx", "pathname": "/how-to-tutorials/tutorials/product-reviews" diff --git a/www/apps/resources/generated/generated-commerce-modules-sidebar.mjs b/www/apps/resources/generated/generated-commerce-modules-sidebar.mjs index e3dc0755ad..3c4e890141 100644 --- a/www/apps/resources/generated/generated-commerce-modules-sidebar.mjs +++ b/www/apps/resources/generated/generated-commerce-modules-sidebar.mjs @@ -11509,6 +11509,14 @@ const generatedgeneratedCommerceModulesSidebarSidebar = { "path": "https://docs.medusajs.com/resources/how-to-tutorials/tutorials/preorder", "children": [] }, + { + "loaded": true, + "isPathHref": true, + "type": "ref", + "title": "Implement Product Builder", + "path": "https://docs.medusajs.com/resources/how-to-tutorials/tutorials/product-builder", + "children": [] + }, { "loaded": true, "isPathHref": true, diff --git a/www/apps/resources/generated/generated-how-to-tutorials-sidebar.mjs b/www/apps/resources/generated/generated-how-to-tutorials-sidebar.mjs index 6dc4181fd3..22b2bb1eff 100644 --- a/www/apps/resources/generated/generated-how-to-tutorials-sidebar.mjs +++ b/www/apps/resources/generated/generated-how-to-tutorials-sidebar.mjs @@ -559,6 +559,15 @@ const generatedgeneratedHowToTutorialsSidebarSidebar = { "description": "Learn how to implement pre-order functionality for products in your Medusa store.", "children": [] }, + { + "loaded": true, + "isPathHref": true, + "type": "link", + "title": "Product Builder", + "path": "/how-to-tutorials/tutorials/product-builder", + "description": "Learn how to implement a product builder that allows customers to customize products before adding them to the cart.", + "children": [] + }, { "loaded": true, "isPathHref": true, diff --git a/www/apps/resources/generated/generated-tools-sidebar.mjs b/www/apps/resources/generated/generated-tools-sidebar.mjs index d912a536b9..d489b10a3d 100644 --- a/www/apps/resources/generated/generated-tools-sidebar.mjs +++ b/www/apps/resources/generated/generated-tools-sidebar.mjs @@ -835,6 +835,14 @@ const generatedgeneratedToolsSidebarSidebar = { "path": "https://docs.medusajs.com/resources/how-to-tutorials/tutorials/preorder", "children": [] }, + { + "loaded": true, + "isPathHref": true, + "type": "ref", + "title": "Implement Product Builder", + "path": "https://docs.medusajs.com/resources/how-to-tutorials/tutorials/product-builder", + "children": [] + }, { "loaded": true, "isPathHref": true, diff --git a/www/apps/resources/sidebars/how-to-tutorials.mjs b/www/apps/resources/sidebars/how-to-tutorials.mjs index 26cb07e7c8..076811c0f4 100644 --- a/www/apps/resources/sidebars/how-to-tutorials.mjs +++ b/www/apps/resources/sidebars/how-to-tutorials.mjs @@ -176,6 +176,13 @@ While tutorials show you a specific use case, they also help you understand how description: "Learn how to implement pre-order functionality for products in your Medusa store.", }, + { + type: "link", + title: "Product Builder", + path: "/how-to-tutorials/tutorials/product-builder", + description: + "Learn how to implement a product builder that allows customers to customize products before adding them to the cart.", + }, { type: "link", title: "Product Reviews", diff --git a/www/packages/tags/src/tags/auth.ts b/www/packages/tags/src/tags/auth.ts index e5599e5e32..f0f3fddff3 100644 --- a/www/packages/tags/src/tags/auth.ts +++ b/www/packages/tags/src/tags/auth.ts @@ -1,12 +1,12 @@ export const auth = [ - { - "title": "Create Actor Type", - "path": "https://docs.medusajs.com/resources/commerce-modules/auth/create-actor-type" - }, { "title": "Reset Password", "path": "https://docs.medusajs.com/user-guide/reset-password" }, + { + "title": "Create Actor Type", + "path": "https://docs.medusajs.com/resources/commerce-modules/auth/create-actor-type" + }, { "title": "Implement Phone Authentication", "path": "https://docs.medusajs.com/resources/how-to-tutorials/tutorials/phone-auth" diff --git a/www/packages/tags/src/tags/nextjs.ts b/www/packages/tags/src/tags/nextjs.ts index 9d5ff715b1..3a924b1912 100644 --- a/www/packages/tags/src/tags/nextjs.ts +++ b/www/packages/tags/src/tags/nextjs.ts @@ -15,6 +15,10 @@ export const nextjs = [ "title": "Implement Pre-Orders", "path": "https://docs.medusajs.com/resources/how-to-tutorials/tutorials/preorder" }, + { + "title": "Implement Product Builder", + "path": "https://docs.medusajs.com/resources/how-to-tutorials/tutorials/product-builder" + }, { "title": "Saved Payment Methods", "path": "https://docs.medusajs.com/resources/how-to-tutorials/tutorials/saved-payment-methods" diff --git a/www/packages/tags/src/tags/product.ts b/www/packages/tags/src/tags/product.ts index d73c5f0839..f61c21aafd 100644 --- a/www/packages/tags/src/tags/product.ts +++ b/www/packages/tags/src/tags/product.ts @@ -79,6 +79,10 @@ export const product = [ "title": "Implement Pre-Order Products", "path": "https://docs.medusajs.com/resources/how-to-tutorials/tutorials/preorder" }, + { + "title": "Implement Product Builder", + "path": "https://docs.medusajs.com/resources/how-to-tutorials/tutorials/product-builder" + }, { "title": "Implement Product Reviews", "path": "https://docs.medusajs.com/resources/how-to-tutorials/tutorials/product-reviews" diff --git a/www/packages/tags/src/tags/server.ts b/www/packages/tags/src/tags/server.ts index 4a3dea2232..66f85063bf 100644 --- a/www/packages/tags/src/tags/server.ts +++ b/www/packages/tags/src/tags/server.ts @@ -83,6 +83,10 @@ export const server = [ "title": "Pre-Orders", "path": "https://docs.medusajs.com/resources/how-to-tutorials/tutorials/preorder" }, + { + "title": "Product Builder", + "path": "https://docs.medusajs.com/resources/how-to-tutorials/tutorials/product-builder" + }, { "title": "Product Reviews", "path": "https://docs.medusajs.com/resources/how-to-tutorials/tutorials/product-reviews" diff --git a/www/packages/tags/src/tags/tutorial.ts b/www/packages/tags/src/tags/tutorial.ts index 78cc5e6269..ae9eeed281 100644 --- a/www/packages/tags/src/tags/tutorial.ts +++ b/www/packages/tags/src/tags/tutorial.ts @@ -51,6 +51,10 @@ export const tutorial = [ "title": "Pre-Orders", "path": "https://docs.medusajs.com/resources/how-to-tutorials/tutorials/preorder" }, + { + "title": "Product Builder", + "path": "https://docs.medusajs.com/resources/how-to-tutorials/tutorials/product-builder" + }, { "title": "Product Reviews", "path": "https://docs.medusajs.com/resources/how-to-tutorials/tutorials/product-reviews"