Implement Personalized Products in Medusa

In this tutorial, you will learn how to implement personalized products in Medusa.

When you install a Medusa application, you get a fully-fledged commerce platform with a Framework for customization. You can benefit from existing features and extend them to implement your business requirements.

Personalized products allow customers to enter custom values for product attributes, such as text or images, before adding them to the cart. This is useful for businesses that offer customizable products, such as furniture, clothing, or gifts.

Summary#

By following this tutorial, you will learn how to:

  • Install and set up a Medusa application.
  • Store personalized product data.
  • Calculate custom pricing based on personalized attributes.
  • Validate personalized product data before adding to the cart.
  • Add personalized products to the cart.
  • Extend the Medusa Admin dashboard to show personalized product data in an order.

You can follow this tutorial whether you're new to Medusa or an advanced Medusa developer.

Diagram showcasing requests sent from the storefront to the server to get custom price, add product to cart, and place order. Then the admin user views the personalized items in an order.

Full Code
Find the full code for this guide in this repository.
OpenApi Specs for Postman
Import this OpenApi Specs file into tools like Postman.

Step 1: Install a Medusa Application#

Start by installing the Medusa application on your machine with the following command:

Terminal
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, 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.

Why is the storefront installed separately? 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. Learn more in Medusa's Architecture documentation.

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.

Ran into Errors? Check out the troubleshooting guides for help.

Step 2: Create a Personalized Product#

In this tutorial, you'll use the example of selling fabric of custom height and width. The customer can enter the height and width of the fabric they want to buy, and the price will be calculated based on these values.

When the customer adds the product variant to the cart, you'll store the personalized data in the line item's metadata property. Note that the same product variant added with different metadata to the cart is treated as a separate line item.

When the customer places the order, the line items' metadata property are copied to the order's line items, allowing you to access the personalized data later.

So, to create a personalized product, you can just create a regular product using the Medusa Admin.

Differentiate Personalized Products#

If you want to support both personalized and non-personalized products, you can set an is_personalized flag in the personalized product's metadata property to differentiate them from regular products.

To do that from the Medusa Admin dashboard:

  1. Go to Products from the sidebar and click on the product you want to edit.
  2. Click on the icon in the "Metadata" section.
  3. In the side window, enter is_personalized as the key and true as the value.
  4. Once you're done, click on the "Save" button.

Screenshot of Medusa Admin metadata editor showing a form field with 'is_personalized' as the key and 'true' as the value

The rest of this tutorial will always check for this is_personalized flag to determine whether the product is personalized or not.


Step 3: Get Personalization Price#

When the customer enters the fabric's height and width, you'll calculate a custom price based on the entered dimensions and the product variant's price. You'll show that price to the customer and, later, use it when adding the product to the cart.

Tip: If your use case doesn't require custom pricing, you can skip this step and just use the product variant's price as is.

In this step, you'll implement the logic to calculate the custom price, then expose that logic to client applications. To do that, you will:

  1. Create a workflow that calculates the price based on the height, width, and product variant's price.
  2. Create an API route that executes the workflow and returns the price.

a. Create a Workflow#

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.

In Medusa, you implement your custom commerce flows in workflows. Then, you execute those workflows from other customizations, such as API routes.

The workflow that calculates the personalized product variant's price will have the following steps:

Medusa provides the useQueryGraphStep, so you only need to implement the getCustomPriceStep step.

getCustomPriceStep

The getCustomPriceStep will calculate a variant's custom price based on its original price, and the height and width values entered by the customer.

To create the step, create the file src/workflows/steps/get-custom-price.ts with the following content:

src/workflows/steps/get-custom-price.ts
1import { ProductVariantDTO } from "@medusajs/framework/types"2import { MedusaError } from "@medusajs/framework/utils"3import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"4
5export type GetCustomPriceStepInput = {6  variant: ProductVariantDTO & {7    calculated_price?: {8      calculated_amount: number9    }10  }11  metadata?: Record<string, unknown>12}13
14const DIMENSION_PRICE_FACTOR = 0.0115
16export const getCustomPriceStep = createStep(17  "get-custom-price",18  async ({19    variant, metadata = {},20  }: GetCustomPriceStepInput) => {21    if (!variant.product?.metadata?.is_personalized) {22      return new StepResponse(variant.calculated_price?.calculated_amount || 0)23    }24    if (!metadata.height || !metadata.width) {25      throw new MedusaError(26        MedusaError.Types.INVALID_DATA,27        "Custom price requires width and height metadata to be set."28      )29    }30    const height = metadata.height as number31    const width = metadata.width as number32
33    const originalPrice = variant.calculated_price?.calculated_amount || 034    const customPrice = originalPrice + (height * width * DIMENSION_PRICE_FACTOR)35
36    return new StepResponse(customPrice)37  }38)

You create a step with the createStep function. It accepts two 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 variant's data and the metadata object containing the height and width values.
    • An object that has properties including the Medusa container, which is a registry of Framework and commerce tools that you can access in the step.

In the step function, you first check if the product is personalized by checking the is_personalized flag in the product's metadata. If it is not personalized, you return the original price.

Then, you calculate the custom price by multiplying the height and width to a 0.01 factor, which means that for every square unit of height and width, the price increases by 0.01 units. You add the variant's original price to the result.

Finally, a step function must return a StepResponse instance that accepts the step's output as a parameter, which is the calculated price.

Create the Workflow

Next, you'll create the workflow that calculates a personalized product variant's price.

Create the file src/workflows/get-custom-price.ts with the following content:

src/workflows/get-custom-price.ts
1import { QueryContext } from "@medusajs/framework/utils"2import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk"3import { useQueryGraphStep } from "@medusajs/medusa/core-flows"4import { getCustomPriceStep, GetCustomPriceStepInput } from "./steps/get-custom-price"5
6type WorkflowInput = {7  variant_id: string8  region_id: string9  metadata?: Record<string, unknown>10}11
12export const getCustomPriceWorkflow = createWorkflow(13  "get-custom-price-workflow",14  (input: WorkflowInput) => {15    const { data: regions } = useQueryGraphStep({16      entity: "region",17      fields: ["currency_code"],18      filters: {19        id: input.region_id,20      },21      options: {22        throwIfKeyNotFound: true,23      },24    })25    const { data: variants } = useQueryGraphStep({26      entity: "variant",27      fields: [28        "*",29        "calculated_price.*",30        "product.*",31      ],32      filters: {33        id: input.variant_id,34      },35      options: {36        throwIfKeyNotFound: true,37      },38      context: {39        calculated_price: QueryContext({40          currency_code: regions[0].currency_code,41        }),42      },43    }).config({ name: "get-custom-price-variant" })44
45    const price = getCustomPriceStep({46      variant: variants[0],47      metadata: input.metadata,48    } as GetCustomPriceStepInput)49
50    return new WorkflowResponse(price)51  }52)

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 variant's ID, the ID of the customer's region, and the metadata object containing the height and width values.

In the workflow, you:

  1. Retrieve the region's currency code using the useQueryGraphStep step. This is necessary to calculate the variant's price in the correct currency.
    • The useQueryGraphStep uses Query to retrieve data across modules.
  2. Retrieve the product variant with its calculated price using the useQueryGraphStep.
  3. Calculate the custom price using the getCustomPriceStep, passing the variant and metadata as input.

A workflow must return an instance of WorkflowResponse that accepts the data to return to the workflow's executor.

b. Create an API Route#

Now that you have the workflow that calculates the custom price, you can create an API route that executes this workflow and returns the price.

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.

Note: Refer to the API routes to learn more about them.

Create the file src/api/store/variants/[id]/price/route.ts with the following content:

src/api/store/variants/[id]/price/route.ts
1import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"2import { getCustomPriceWorkflow } from "../../../../../workflows/get-custom-price"3import { z } from "zod"4
5export const PostCustomPriceSchema = z.object({6  region_id: z.string(),7  metadata: z.object({8    height: z.number(),9    width: z.number(),10  }),11})12
13type PostCustomPriceSchemaType = z.infer<typeof PostCustomPriceSchema>14
15export async function POST(16  req: MedusaRequest<PostCustomPriceSchemaType>,17  res: MedusaResponse18) {19  const { id: variantId } = req.params20  const { 21    region_id,22    metadata,23  } = req.validatedBody24
25  const { result: price } = await getCustomPriceWorkflow(req.scope).run({26    input: {27      variant_id: variantId,28      region_id,29      metadata,30    },31  })32
33  res.json({34    price,35  })36}

You create the PostCustomPriceSchema schema that is used to validate request bodies sent to this API route with Zod.

Then, you export a POST route handler function, which will expose a POST API route at /store/variants/:id/price.

In the route handler, you execute the getCustomPriceWorkflow workflow by invoking it, passing the Medusa container (stored in req.scope), then executing its run method.

Finally, you return the price in the response.

c. Add Validation Middleware#

To ensure that the API route receives the correct request body parameters, you can apply a middleware on the API route that validates incoming requests.

To apply middlewares, create the file src/api/middlewares.ts with the following content:

src/api/middlewares.ts
1import { defineMiddlewares, validateAndTransformBody } from "@medusajs/framework/http"2import { PostCustomPriceSchema } from "./store/variants/[id]/price/route"3
4export default defineMiddlewares({5  routes: [6    {7      matcher: "/store/variants/:id/price",8      methods: ["POST"],9      middlewares: [10        validateAndTransformBody(PostCustomPriceSchema),11      ],12    },13  ],14})

You apply Medusa's validateAndTransformBody middleware to POST requests sent to the /store/variants/:id/price route.

The middleware function accepts a Zod schema used for validation. This is the schema you created in the API route's file.

Tip: Refer to the Middlewares documentation to learn more.

You'll test out the API route when you customize the storefront in the next step.


Step 4: Show Calculated Price in the Storefront#

In this step, you'll customize the Next.js Starter Storefront to show a personalized product's custom price when the customer enters the height and width values.

Reminder: 

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-personalized, you can find the storefront by going back to the parent directory and changing to the medusa-personalized-storefront directory:

Terminal
cd ../medusa-personalized-storefront # change based on your project name

a. Add Server Function#

The first step is to add a server function that retrieves the custom price from the API route you created.

In src/lib/data/products.ts, add the following function:

Storefront
src/lib/data/products.ts
1export const getCustomVariantPrice = async ({2  variant_id,3  region_id,4  metadata,5}: {6  variant_id: string7  region_id: string8  metadata?: Record<string, any>9}) => {10  const headers = {11    ...(await getAuthHeaders()),12  }13
14  return sdk.client15    .fetch<{ price: number }>(16      `/store/variants/${variant_id}/price`,17      {18        method: "POST",19        body: {20          region_id,21          metadata,22        },23        headers,24        cache: "no-cache",25      }26    )27    .then(({ price }) => price)28}

You create a getCustomVariantPrice function that accepts the variant's ID, the region's ID, and the metadata object containing the height and width values.

In the function, you use the JS SDK to send a POST request to the /store/variants/:id/price API route you created in the previous step. The function returns the price from the response.

b. Customize the Product Price Component#

Next, you'll customize the product price component to show the custom price when the product is personalized.

The ProductPrice component is located in src/modules/products/components/product-price/index.tsx. It shows either the product's cheapest variant price, or the selected variant's price.

Replace the content of the file with the following:

Storefront
src/modules/products/components/product-price/index.tsx
1import { clx } from "@medusajs/ui"2import { HttpTypes } from "@medusajs/types"3import { useEffect, useMemo, useState } from "react"4import { getCustomVariantPrice } from "../../../../lib/data/products"5import { convertToLocale } from "../../../../lib/util/money"6
7export default function ProductPrice({8  product,9  variant,10  metadata,11  region,12}: {13  product: HttpTypes.StoreProduct14  variant?: HttpTypes.StoreProductVariant15  metadata?: Record<string, any>16  region: HttpTypes.StoreRegion17}) {18  const [price, setPrice] = useState(0)19  20  useEffect(() => {21    if (22      !variant ||23      (product.metadata?.is_personalized && (24        !metadata?.height || !metadata?.width25      ))26    ) {27      return28    }29
30    getCustomVariantPrice({31      variant_id: variant.id,32      region_id: region.id,33      metadata,34    })35      .then((price) => {36        setPrice(price)37      })38      .catch((error) => {39        console.error("Error fetching custom variant price:", error)40      })41  }, [metadata, variant])42
43  const displayPrice = useMemo(() => {44    return convertToLocale({45      amount: price,46      currency_code: region.currency_code,47    })48  }, [price])49
50  return (51    <div className="flex flex-col text-ui-fg-base">52      <span53        className={clx("text-xl-semi")}54      >55        {price > 0 && <span56          data-testid="product-price"57          data-value={displayPrice}58        >59          {displayPrice}60        </span>}61      </span>62    </div>63  )64}

You make the following main changes:

  1. Add the metadata and region props to the component, since you need them to retrieve the custom price.
  2. Define a price state variable to hold the custom price.
  3. Use the useEffect hook to call the getCustomVariantPrice function whenever the variant, metadata, or region changes.
  4. Use the useMemo hook to convert the price to the locale format using the convertToLocale function.
  5. Display the custom price in the component.

c. Add Height and Width Inputs#

Next, you'll customize the parent component of the ProductPrice component to pass the new props to it, and to show the height and width inputs.

In src/modules/products/components/product-actions/index.tsx, add the following import at the top of the file:

Storefront
src/modules/products/components/product-actions/index.tsx
import Input from "../../../common/components/input"

Next, pass the region prop to the ProductActions component. The props' type already defines it but it's not included in the destructured props:

Storefront
src/modules/products/components/product-actions/index.tsx
1export default function ProductActions({2  // ...3  region,4}: ProductActionsProps) {5  // ...6}

Then, add new state variables in the ProductActions component for the height and width values:

Storefront
src/modules/products/components/product-actions/index.tsx
1const [height, setHeight] = useState(0)2const [width, setWidth] = useState(0)

After that, find the ProductPrice component in the return statement and replace it with the following:

Storefront
src/modules/products/components/product-actions/index.tsx
1return (2    <>3      {/* ... */}4      <div className="flex flex-col gap-y-2">5        {!!product.metadata?.is_personalized && (6          <div className="flex flex-col gap-y-3">7            <span className="text-sm">Enter Dimensions</span>8            <div className="flex gap-3">9              <Input10                name="width"11                value={width}12                onChange={(e) => setWidth(Number(e.target.value))}13                label="Width (cm)"14                type="number"15                min={0}16              />17              <Input18                name="height"19                value={height}20                onChange={(e) => setHeight(Number(e.target.value))}21                label="Height (cm)"22                type="number"23                min={0}24              />25            </div>26          </div>27        )}28      </div>29
30      <ProductPrice 31        product={product} 32        variant={selectedVariant}33        region={region}34        metadata={{ width, height }}35      />36      {/* ... */}37    </>38)

You add a new section that shows the height and width inputs when the product is personalized. The inputs update the height and width state variables.

Then, you pass the new metadata and region props to the ProductPrice component.

Finally, update the add-to-cart button's disabled prop to check if the product is personalized and if the height and width values are set:

Storefront
src/modules/products/components/product-actions/index.tsx
1return (2  <>3    {/* ... */}4    <Button5      disabled={6        !inStock ||7        !selectedVariant ||8        !!disabled ||9        isAdding ||10        !isValidVariant ||11        (!!product.metadata?.is_personalized && (!width || !height))12      }13      // ...14    >15      {/* ... */}16    </Button>17    {/* ... */}18  </>19)

Test Price Customization#

You can now test the price customization in the storefront.

First, start the Medusa application with the following command:

Then, start the Next.js Starter Storefront with the following command:

Open the storefront in your browser at http://localhost:8000. Go to Menu -> Store and click on the personalized product you created.

You should see the height and width inputs below other product variant options. Once you enter the height and width values, the price will be shown and updated based on the values you entered.

Screenshot of a product page in the Next.js storefront showing a fabric product with two input fields labeled 'Width (cm)' and 'Height (cm)' below the product options, and a calculated price displayed underneath the dimension inputs


Step 5: Implement Custom Add-to-Cart Logic#

In this step, you'll implement custom logic to add personalized products to the cart.

When the customer adds a personalized product to the cart, you need to add the item to the cart with the calculated price.

So, you'll create a workflow that wraps around Medusa's existing add-to-cart logic to add the personalized product to the cart with the custom price. Then, you'll create an API route that executes this workflow.

Tip: If you're not using custom pricing, you can skip this step and keep on using Medusa's existing Add-to-Cart API route.

a. Create the Add-to-Cart Workflow#

The custom add-to-cart workflow will have the following steps:

You already have all the necessary steps within the workflow, so you create it right away.

Create the file src/workflows/custom-add-to-cart.ts with the following content:

src/workflows/custom-add-to-cart.ts
1import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk"2import { addToCartWorkflow, useQueryGraphStep } from "@medusajs/medusa/core-flows"3import { getCustomPriceWorkflow } from "./get-custom-price"4
5type CustomAddToCartWorkflowInput = {6  item: {7    variant_id: string;8    quantity?: number;9    metadata?: Record<string, unknown>;10  }11  cart_id: string;12}13
14export const customAddToCartWorkflow = createWorkflow(15  "custom-add-to-cart",16  (input: CustomAddToCartWorkflowInput) => {17    const { data: carts } = useQueryGraphStep({18      entity: "cart",19      fields: ["region_id"],20      filters: {21        id: input.cart_id,22      },23      options: {24        throwIfKeyNotFound: true,25      },26    })27    const price = getCustomPriceWorkflow.runAsStep({28      input: {29        variant_id: input.item.variant_id,30        region_id: carts[0].region_id!,31        metadata: input.item.metadata,32      },33    })34
35    const itemData = transform({36      item: input.item,37      price,38    }, (data) => {39      return {40        variant_id: data.item.variant_id,41        quantity: data.item.quantity || 1,42        metadata: data.item.metadata,43        unit_price: data.price,44      }45    })46
47    addToCartWorkflow.runAsStep({48      input: {49        cart_id: input.cart_id,50        items: [itemData],51      },52    })53
54    // refetch the updated cart55    const { data: updatedCart } = useQueryGraphStep({56      entity: "cart",57      fields: ["*", "items.*"],58      filters: {59        id: input.cart_id,60      },61    }).config({ name: "refetch-cart" })62
63    return new WorkflowResponse({64      cart: updatedCart[0],65    })66  }67)

The workflow accepts the item to add to the cart, which includes the variant's ID, quantity, and metadata, as well as the cart's ID.

In the workflow, you:

  1. Retrieve the cart's region using the useQueryGraphStep.
  2. Calculate the custom price using the getCustomPriceWorkflow that you created earlier.
  3. Prepare the data of the item to add to the cart.
    • You use the transform function because direct data manipulation isn't allowed in workflows. Refer to the Data Manipulation guide to learn more.
  4. Add the item to the cart using the addToCartWorkflow.
  5. Refetch the updated cart using the useQueryGraphStep to return the cart data in the workflow's response.

b. Create the Add-to-Cart API Route#

Next, you'll create an API route that executes the custom add-to-cart workflow.

Create the file src/api/store/carts/[id]/line-items-custom/route.ts with the following content:

src/api/store/carts/[id]/line-items-custom/route.ts
1import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"2import { z } from "zod"3import { 4  customAddToCartWorkflow,5} from "../../../../../workflows/custom-add-to-cart"6
7export const PostAddCustomLineItemSchema = z.object({8  variant_id: z.string(),9  quantity: z.number().optional(),10  metadata: z.record(z.unknown()).optional(),11})12
13type PostAddCustomLineItemSchemaType = z.infer<14  typeof PostAddCustomLineItemSchema15>;16
17export async function POST(18  req: MedusaRequest<PostAddCustomLineItemSchemaType>,19  res: MedusaResponse20) {21  const { id: cartId } = req.params22  const { result: cart } = await customAddToCartWorkflow(req.scope).run({23    input: {24      item: {25        variant_id: req.validatedBody.variant_id,26        quantity: req.validatedBody.quantity,27        metadata: req.validatedBody.metadata,28      },29      cart_id: cartId,30    },31  })32
33  res.json({34    cart,35  })36}

You create a PostAddCustomLineItemSchema schema to validate the request body sent to this API route.

Then, you export a POST route handler function which will expose a POST API route at /store/carts/:id/line-items-custom.

In the route handler, you execute the customAddToCartWorkflow workflow passing it the item's details and cart ID as input.

Finally, you return the updated cart in the response.

c. Add Validation Middleware#

Similar to the previous API route, you'll apply a validation middleware to the API route to ensure that the request body is valid.

In src/api/middlewares.ts, add a new middleware for the custom add-to-cart API route:

src/api/middlewares.ts
1// other imports...2import { 3  PostAddCustomLineItemSchema,4} from "./store/carts/[id]/line-items-custom/route"5
6export default defineMiddlewares({7  routes: [8    // ...9    {10      matcher: "/store/carts/:id/line-items-custom",11      methods: ["POST"],12      middlewares: [13        validateAndTransformBody(PostAddCustomLineItemSchema),14      ],15    },16  ],17})

This applies the validateAndTransformBody middleware to POST requests sent to the /store/carts/:id/line-items-custom route, validating the request body against the schema you created.

You'll test out this API route when you customize the storefront in the next steps.


Step 6: Validate Personalized Products Added to Cart#

In this step, you'll ensure that the personalized products added to the cart include the height and width values in their metadata property.

Medusa's addToCartWorkflow workflow supports performing custom validation on the items being added to the cart using workflow hooks. A workflow hook is a point in a workflow where you can inject custom functionality as a step function.

To consume the validate hook that runs before an item is added to the cart, create the file src/workflows/hooks/validate-personalized-product.ts with the following content:

src/workflows/hooks/validate-personalized-product.ts
1import { MedusaError } from "@medusajs/framework/utils"2import { addToCartWorkflow } from "@medusajs/medusa/core-flows"3
4addToCartWorkflow.hooks.validate(5  async ({ input }, { container }) => {6    const query = container.resolve("query")7    const { data: variants } = await query.graph({8      entity: "variant",9      fields: ["product.*"],10      filters: {11        id: input.items.map((item) => item.variant_id).filter(Boolean) as string[],12      },13    })14    for (const item of input.items) {15      const variant = variants.find((v) => v.id === item.variant_id)16      if (!variant?.product?.metadata?.is_personalized) {17        continue18      }19      if (20        !item.metadata?.height || !item.metadata.width ||21        isNaN(Number(item.metadata.height)) || isNaN(Number(item.metadata.width))22      ) {23        throw new MedusaError(24          MedusaError.Types.INVALID_DATA,25          "Please set height and width metadata for each item."26        )27      }28    }29  }30)

You consume the hook by calling addToCartWorkflow.hooks.validate, passing it a step function.

In the step function, you:

  1. Resolve Query from the Medusa container to retrieve data across modules.
  2. Retrieve the product variants being added to the cart.
  3. Loop through the items being added to the cart.
  4. If an item's product is personalized, you validate that the metadata object contains the height and width values, and that they are valid numbers. Otherwise, you throw an error.

If the hook throws an error, the addToCartWorkflow will not proceed with adding the item to the cart, and the error will be returned in the API response.

Note: Refer to the Workflow Hooks documentation to learn more.

You can test this out after customizing the storefront in the next section.


Step 7: Use the Custom Add-to-Cart API Route in Storefront#

In this step, you'll customize the Next.js Starter Storefront to use the custom add-to-cart API route when a customer adds a product to the cart.

You'll also customize components showing items in the cart and order confirmation to display the personalized product's height and width values.

a. Customize Add-to-Cart Server Function#

The addToCart function defined in src/lib/data/cart.ts is used to add items to the cart. You'll customize it to use the custom add-to-cart API route.

First, find the addToCart function and change its parameters to accept the metadata object:

Storefront
src/lib/data/cart.ts
1export async function addToCart({2  variantId,3  quantity,4  countryCode,5  metadata = {},6}: {7  variantId: string8  quantity: number9  countryCode: string10  metadata?: Record<string, any>11}) {12  // ...13}

Then, find the following lines in the function:

Storefront
src/lib/data/cart.ts
1await sdk.store.cart2  .createLineItem(3    cart.id,4    {5      variant_id: variantId,6      quantity,7    },8    {},9    headers10  )

And replace them with the following:

Storefront
src/lib/data/cart.ts
1await sdk.client.fetch<{2  cart: HttpTypes.StoreCart3}>(`/store/carts/${cart.id}/line-items-custom`, {4  method: "POST",5  body: {6    variant_id: variantId,7    quantity,8    metadata,9  },10  headers,11})

You send a request to the API route you created in the previous step, passing the variant ID, quantity, and metadata in the request body.

Next, you'll need to pass the metadata object when calling the addToCart function.

In src/modules/products/components/product-actions/index.tsx, find the addToCart function call in the handleAddToCart function and pass it the metadata object:

Storefront
src/modules/products/components/product-actions/index.tsx
1await addToCart({2  // ...3  metadata: {4    width,5    height,6  },7})

Now, when the customer adds a personalized product to the cart, the height and width values will be sent to the API route.

b. Customize Cart Item Component#

Next, you'll customize the cart item component to show the height and width values for personalized products.

In src/modules/cart/components/item/index.tsx, add the following in the Item component's return statement, right after the LineItemOptions component:

Storefront
src/modules/cart/components/item/index.tsx
1return (2  <Table.Row className="w-full" data-testid="product-row">3    {/* ... */}4    <LineItemOptions variant={item.variant} data-testid="product-variant" />5    <div className="text-sm text-ui-fg-muted">6      {!!item.metadata?.width && <div>Width: {item.metadata.width as number}cm</div>}7      {!!item.metadata?.height && <div>Height: {item.metadata.height as number}cm</div>}8    </div>9    {/* ... */}10  </Table.Row>11)

You show the height and width values from the item's metadata object if they exist. This will display the dimensions of personalized products in the cart.

c. Customize Order Confirmation Page#

Finally, you'll customize the order item component in the order confirmation page to show the height and width values for personalized products.

In src/modules/orders/components/order-item/index.tsx, add the following in the Item component's return statement, right after the LineItemOptions component:

Storefront
src/modules/orders/components/order-item/index.tsx
1return (2  <Table.Row className="w-full" data-testid="product-row">3    {/* ... */}4    <LineItemOptions variant={item.variant} data-testid="product-variant" />5    <div className="text-sm text-ui-fg-muted">6      {!!item.metadata?.width && <div>Width: {item.metadata.width as number}cm</div>}7      {!!item.metadata?.height && <div>Height: {item.metadata.height as number}cm</div>}8    </div>9    {/* ... */}10  </Table.Row>11)

Similarly, you show the height and width values from the item's metadata object if they exist. This will display the dimensions of personalized products in the order confirmation page.

Test the Custom Add-to-Cart Logic#

To test out the custom add-to-cart logic, ensure that both the Medusa application and the Next.js Starter Storefront are running.

Then, add a personalized product to the cart after choosing any necessary product options and entering the height and width values. You should see the product added to the cart with the correct price based on the dimensions you entered.

If you open the cart page by clicking on "Cart" at the top right, you can see the personalized product's height and width values displayed after the product variant options.

Screenshot of the shopping cart page showing a personalized fabric product with 'Width: 100cm' and 'Height: 80cm' displayed below the product variant information, demonstrating how custom dimensions are preserved in the cart

Place Order with Personalized Product

You can also proceed to the checkout page and complete the order. The order confirmation page will show the personalized product with its height and width values.


Step 8: Show an Order's Personalized Items in Medusa Admin#

In this step, you'll customize the Medusa Admin to show the personalized item's height and width values in an order's details page.

The Medusa Admin dashboard is extensible, allowing you to either inject custom components into existing pages, or create new pages.

In this case, you'll inject a custom component, called a widget, into the order details page.

Widgets are created in a .tsx file under the src/admin/widgets directory. So, create the file src/admin/widgets/order-personalized.tsx with the following content:

src/admin/widgets/order-personalized.tsx
1import { defineWidgetConfig } from "@medusajs/admin-sdk"2import { Container, Heading, Text } from "@medusajs/ui"3import { AdminOrder, DetailWidgetProps } from "@medusajs/framework/types"4
5const PersonalizedOrderItemsWidget = ({ 6  data: order,7}: DetailWidgetProps<AdminOrder>) => {8  const items = order.items.filter((item) => {9    return item.variant?.product?.metadata?.is_personalized10  })11
12  if (!items.length) {13    return <></>14  }15
16  return (17    <Container className="divide-y p-0">18      <div className="flex items-center justify-between px-6 py-4">19        <Heading level="h2">Personalized Order Items</Heading>20      </div>21      <div className="divide-y">22        {items.map((item) => (23          <div key={item.id} className="flex gap-4 px-6 py-4">24            {item.variant?.product?.thumbnail && <img25              src={item.variant.product.thumbnail}26              alt={item.variant.title || "Personalized Product"}27              className="h-8 w-6 object-cover rounded border border-ui-border"28            />}29            <div className="flex flex-col">30              <Text size="small" weight="plus">31                {item.variant?.product?.title}: {item.variant?.title}32              </Text>33              <Text size="small" className="text-ui-fg-subtle">34                Width (cm): {item.metadata?.width as number || "N/A"}35              </Text>36              <Text size="small" className="text-ui-fg-subtle">37                Height (cm): {item.metadata?.height as number || "N/A"}38              </Text>39            </div>40          </div>41        ))}42      </div>43    </Container>44  )45}46
47export const config = defineWidgetConfig({48  zone: "order.details.after",49})50
51export default PersonalizedOrderItemsWidget

A widget file must export:

  • A default React component. This component renders the widget's UI.
  • A config object created with the defineWidgetConfig function. It accepts an object with the zone property that indicates where the widget will be rendered in the Medusa Admin dashboard.

The widget component accepts a data prop that contains the order data.

In the component, you retrieve the items whose product is personalized. Then, you display those items with their height and width values. Remember, the metadata property is copied from the cart's line items to the order's line items.

If there are no personalized items in the order, you don't show the widget.

Test the Personalized Order Items Widget#

To test out the personalized order items widget, start the Medusa application and open the Medusa Admin dashboard in your browser at http://localhost:9000/app.

Go to Orders and click on an order that contains a personalized product. You should see the "Personalized Order Items" widget displaying the personalized items with their dimensions.

Screenshot of Medusa Admin order details page showing a custom widget titled 'Personalized Order Items' with a fabric product entry displaying the product image, title, and dimensions 'Width (cm): 100' and 'Height (cm): 80' in a clean list format


Next Steps#

You've now implemented personalized products in Medusa, allowing customers to customize product dimensions and see the calculated price in the storefront. You can expand on this feature to:

  • Add more personalization options, such as text engraving.
  • Implement more complex pricing calculations based on additional metadata.
  • Create a custom fulfillment provider to handle personalized products differently during fulfillment.

Learn More about Medusa#

If you're new to Medusa, check out the main documentation, 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.

Troubleshooting#

If you encounter issues during your development, check out the troubleshooting guides.

Getting Help#

If you encounter issues not covered in the troubleshooting guides:

  1. Visit the Medusa GitHub repository to report issues or ask questions.
  2. Join the Medusa Discord community for real-time support from community members.
Was this page helpful?
Ask Anything
FAQ
What is Medusa?
How can I create a module?
How can I create a data model?
How do I create a workflow?
How can I extend a data model in the Product Module?
Recipes
How do I build a marketplace with Medusa?
How do I build digital products with Medusa?
How do I build subscription-based purchases with Medusa?
What other recipes are available in the Medusa documentation?
Chat is cleared on refresh
Line break