Implement Customer Tiers in Medusa
In this tutorial, you'll learn how to implement a customer tiers system 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, which are available out-of-the-box. These features include customer and promotion management capabilities.
A customer tiers system allows you to segment customers based on their purchase history and automatically apply promotions to their carts. Customers are assigned to tiers based on their total purchase value, and each tier can have an associated promotion that is automatically applied to their carts.
Summary#
By following this tutorial, you will learn how to:
- Install and set up Medusa.
- Create a Tier Module to manage customer tiers and tier rules.
- Customize the Medusa Admin to manage tiers.
- Automatically assign customers to tiers based on their purchase history.
- Automatically apply tier promotions to customer carts.
- Customize the Next.js Starter Storefront to display tier information to customers.
You can follow this tutorial whether you're new to Medusa or an advanced Medusa developer.

Step 1: Install a Medusa Application#
Start by installing the Medusa application on your machine with the following command:
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.
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.
Step 2: Create Tier Module#
In Medusa, you can build custom features in a module. A module is a reusable package with functionalities related to a single feature or domain. Medusa integrates the module into your application without affecting your setup.
In the module, you define the data models necessary for a feature and the logic to manage these data models. Later, you can build commerce flows around your module.
In this step, you'll build a Tier Module that defines the necessary data models to store and manage customer tiers and tier rules.
Create Module Directory#
Modules are created under the src/modules directory of your Medusa application. So, create the directory src/modules/tier.
Create Data Models#
A data model represents a table in the database. You create data models using Medusa's Data Model Language (DML), which simplifies defining a table's columns, relations, and indexes with straightforward methods and configurations.
For the Tier Module, you need to define two data models:
Tier: Represents a customer tier (for example, Bronze, Silver, Gold).TierRule: Represents the rules that determine when a customer qualifies for a tier (for example, minimum purchase value in a specific currency).
So, create the file src/modules/tier/models/tier.ts with the following content:
1import { model } from "@medusajs/framework/utils"2import { TierRule } from "./tier-rule"3 4export const Tier = model.define("tier", {5 id: model.id().primaryKey(),6 name: model.text(),7 promo_id: model.text().nullable(),8 tier_rules: model.hasMany(() => TierRule, {9 mappedBy: "tier",10 }),11})
You define the Tier data model using the model.define method of the DML. It accepts the data model's table name as a first parameter, and the model's schema object as a second parameter.
The Tier data model has the following properties:
id: A unique ID for the tier.name: The name of the tier (for example, "Bronze", "Silver", "Gold").promo_id: The ID of the promotion associated with this tier.tier_rules: A one-to-many relationship withTierRuledata model. Ignore the type error as you'll define theTierRuledata model next.
Next, create the file src/modules/tier/models/tier-rule.ts with the following content:
1import { model } from "@medusajs/framework/utils"2import { Tier } from "./tier"3 4export const TierRule = model.define("tier_rule", {5 id: model.id().primaryKey(),6 min_purchase_value: model.number(),7 currency_code: model.text(),8 tier: model.belongsTo(() => Tier, {9 mappedBy: "tier_rules",10 }),11})12.indexes([13 {14 on: ["tier_id", "currency_code"],15 unique: true,16 },17])
You define the TierRule data model with the following properties:
id: A unique ID for the tier rule.min_purchase_value: The minimum purchase value required to qualify for the tier.currency_code: The currency code for which this rule applies (for example,usd,eur).tier: A many-to-one relationship with theTierdata model.
You also add a unique index on tier_id and currency_code to ensure that each tier has only one rule per currency.
Create Module's Service#
You now have the necessary data models in the Tier Module, but you'll need to manage their records. You do this by creating a service in the module.
A service is a TypeScript or JavaScript class that the module exports. In the service's methods, you can connect to the database to manage your data models, or connect to a third-party service, which is useful when integrating with external systems.
To create the Tier Module's service, create the file src/modules/tier/service.ts with the following content:
The TierModuleService extends MedusaService from the Modules SDK, which generates a class with data-management methods for your module's data models. This saves you time implementing Create, Read, Update, and Delete (CRUD) methods.
So, the TierModuleService class now has methods like createTiers, retrieveTier, listTierRules, and more.
Export 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/tier/index.ts with the following content:
You use the Module function from the Modules SDK to create the module's definition. It accepts two parameters:
- The module's name, which is
tier. - An object with a required property
serviceindicating the module's service.
You also export the module's name as TIER_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:
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 to create them in the database using migrations. A migration is a TypeScript or JavaScript file that defines database changes made by a module.
Medusa's CLI tool can generate the migrations for you. To generate a migration for the Tier Module, run the following command in your Medusa application's directory:
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/tier that holds the generated migration.
Then, to reflect these migrations on the database, run the following command:
The tables for the Tier and TierRule data models are now created in the database.
Step 3: Define Module Links#
When you defined the Tier data model, you added properties that store IDs of records managed by other modules. For example, the promo_id property stores a promotion ID, but promotions are managed by the Promotion Module.
Medusa integrates modules into your application without side effects by isolating them from one another. This means you can't directly create relationships between data models in your module and data models in other modules.
Instead, Medusa provides a mechanism to define links between data models and to retrieve and manage linked records while maintaining module isolation. Links are useful for defining associations between data models in different modules or for extending a model in another module to associate custom properties with it.
In this step, you'll define:
- A link between the Tier Module's
Tierdata model and the Customer Module'sCustomerdata model. - A read-only link between the Tier Module's
Tierdata model and the Promotion Module'sPromotiondata model.
Define Tier ↔ Customer Link#
You can define links between data models in a TypeScript or JavaScript file under the src/links directory. So, create the file src/links/tier-customer.ts with the following content:
1import { defineLink } from "@medusajs/framework/utils"2import TierModule from "../modules/tier"3import CustomerModule from "@medusajs/medusa/customer"4 5export default defineLink(6 {7 linkable: TierModule.linkable.tier,8 filterable: ["id"],9 },10 {11 linkable: CustomerModule.linkable.customer,12 isList: true,13 }14)
You define a link using the defineLink function from the Modules SDK. It accepts two parameters:
- An object indicating the first data model in the link. You pass the link configurations for the
Tierdata model from the Tier Module. You also specify theidproperty as filterable, allowing you to filter customers by their tier later using the Index Module. - An object indicating the second data model in the link. You pass the linkable configurations of the Customer Module's
Customerdata model. You setisListtotruebecause a tier can have multiple customers.
This link allows you to retrieve and manage customers associated with a tier, and vice versa.
Define Tier ↔ Promotion Link#
Next, create the file src/links/tier-promotion.ts with the following content:
1import { defineLink } from "@medusajs/framework/utils"2import TierModule from "../modules/tier"3import PromotionModule from "@medusajs/medusa/promotion"4 5export default defineLink(6 {7 linkable: TierModule.linkable.tier,8 field: "promo_id",9 },10 PromotionModule.linkable.promotion,11 {12 readOnly: true,13 }14)
You define a link between the Tier data model and the Promotion data model. You specify that the promo_id field in the Tier data model holds the ID of the linked promotion. You also set readOnly to true because you only want to retrieve the linked promotion without managing the link itself.
You can now retrieve the promotion associated with a tier, as you'll see in later steps.
Step 4: Create Tier#
Now that you have the Tier Module set up, you'll add the functionality to create tiers. This requires creating:
- A workflow with steps to create a tier.
- An API route that exposes the workflow's functionality to client applications.
Later, you'll customize the Medusa Admin to allow creating tiers from the dashboard.
a. Create Tier Workflow#
To build custom commerce features in Medusa, you create a workflow. A workflow is a series of queries and actions, called steps, that complete a task. You can track the workflow's execution progress, define rollback logic, and configure other advanced features.
The workflow to create a tier has the following steps:
Workflow hook
Step conditioned by when
View step details
Medusa provides the last step out of the box. You'll create the other steps before creating the workflow.
Create Tier Step
First, you'll create a step that creates a tier. Create the file src/workflows/steps/create-tier.ts with the following content:
1import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"2import { TIER_MODULE } from "../../modules/tier"3 4export type CreateTierStepInput = {5 name: string6 promo_id: string | null7}8 9export const createTierStep = createStep(10 "create-tier",11 async (input: CreateTierStepInput, { container }) => {12 const tierModuleService = container.resolve(TIER_MODULE)13 14 const tier = await tierModuleService.createTiers({15 name: input.name,16 promo_id: input.promo_id || null,17 })18 19 return new StepResponse(tier, tier)20 },21 async (tier, { container }) => {22 if (!tier) {23 return24 }25 26 const tierModuleService = container.resolve(TIER_MODULE)27 await tierModuleService.deleteTiers(tier.id)28 }29)
You create a step with createStep from the Workflows SDK. It accepts three parameters:
- The step's unique name, which is
create-tier. - An async function that receives two parameters:
- The step's input, which is in this case an object with the tier's properties.
- 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.
- An async compensation function that undoes the actions performed in the step if an error occurs during the workflow's execution.
In the step function, you resolve the Tier Module's service from the Medusa container and create the tier using the createTiers method.
A step function must return a StepResponse instance. The StepResponse constructor accepts two parameters:
- The step's output, which is the tier created.
- Data to pass to the step's compensation function.
In the compensation function, you delete the tier if an error occurs during the workflow's execution.
Create Tier Rules Step
The createTierRulesStep creates tier rules for a tier.
Create the file src/workflows/steps/create-tier-rules.ts with the following content:
1import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"2import { TIER_MODULE } from "../../modules/tier"3 4export type CreateTierRulesStepInput = {5 tier_id: string6 tier_rules: Array<{7 min_purchase_value: number8 currency_code: string9 }>10}11 12export const createTierRulesStep = createStep(13 "create-tier-rules",14 async (input: CreateTierRulesStepInput, { container }) => {15 const tierModuleService = container.resolve(TIER_MODULE)16 17 const createdRules = await tierModuleService.createTierRules(18 input.tier_rules.map((rule) => ({19 tier_id: input.tier_id,20 min_purchase_value: rule.min_purchase_value,21 currency_code: rule.currency_code,22 }))23 )24 25 return new StepResponse(createdRules, createdRules)26 },27 async (createdRules, { container }) => {28 if (!createdRules?.length) {29 return30 }31 32 const tierModuleService = container.resolve(TIER_MODULE)33 await tierModuleService.deleteTierRules(createdRules.map((rule) => rule.id))34 }35)
This step receives the rules to create with the ID of the tier they belong to.
In the step function, you create the tier rules. In the compensation function, you delete them if an error occurs during the workflow's execution.
Create Tier Workflow
You can now create the workflow that creates a tier.
Create the file src/workflows/create-tier.ts with the following content:
9import { createTierRulesStep } from "./steps/create-tier-rules"10 11export type CreateTierWorkflowInput = {12 name: string13 promo_id?: string | null14 tier_rules?: Array<{15 min_purchase_value: number16 currency_code: string17 }>18}19 20export const createTierWorkflow = createWorkflow(21 "create-tier",22 (input: CreateTierWorkflowInput) => {23 // Validate promotion if provided24 when({ input }, (data) => !!data.input.promo_id)25 .then(() => {26 useQueryGraphStep({27 entity: "promotion",28 fields: ["id"],29 filters: {30 id: input.promo_id!,31 },32 options: {33 throwIfKeyNotFound: true,34 },35 })36 })37 // Create the tier38 const tier = createTierStep({39 name: input.name,40 promo_id: input.promo_id || null,41 })42 43 // Create tier rules if provided44 when({ input }, (data) => {45 return !!data.input.tier_rules?.length46 }).then(() => {47 return createTierRulesStep({48 tier_id: tier.id,49 tier_rules: input.tier_rules!,50 })51 })52 53 // Retrieve the created tier with rules54 const { data: tiers } = useQueryGraphStep({55 entity: "tier",56 fields: ["*", "tier_rules.*"],57 filters: {58 id: tier.id,59 },60 }).config({ name: "retrieve-tier" })61 62 return new WorkflowResponse({63 tier: tiers[0],64 })65 }66)
You create a workflow using createWorkflow from the Workflows SDK. It accepts the workflow's unique name as a first parameter.
It accepts as a second parameter a constructor function, which is the workflow's implementation. The function can accept input, which in this case is an object with the tier's details.
In the workflow's constructor function, you:
- Use when-then to check whether the promotion ID is provided and retrieve the promotion to validate that it exists.
- By specifying the
throwIfKeyNotFoundoption, theuseQueryGraphStepthrows an error if the promotion isn't found, which stops the workflow's execution. - This step uses Query under the hood to retrieve data across modules.
- By specifying the
- Create the tier using the
createTierStep. - Use when-then to conditionally create tier rules if they're provided using the
createTierRulesStep. - Retrieve the created tier with its rules using
useQueryGraphStep.
Finally, you return a WorkflowResponse with the created tier.
when-then to check conditions based on execution values. Learn more in the Conditions workflow documentation.b. Create Tier API Route#
Now that you have the workflow to create tiers, you'll create an API route that exposes this functionality to client applications.
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.
Create the file src/api/admin/tiers/route.ts with the following content:
1import {2 MedusaRequest,3 MedusaResponse,4} from "@medusajs/framework/http"5import { z } from "zod"6import { createTierWorkflow } from "../../../workflows/create-tier"7 8export const CreateTierSchema = z.object({9 name: z.string(),10 promo_id: z.string().nullable(),11 tier_rules: z.array(z.object({12 min_purchase_value: z.number(),13 currency_code: z.string(),14 })),15})16 17type CreateTierInput = z.infer<typeof CreateTierSchema>18 19export async function POST(20 req: MedusaRequest<CreateTierInput>,21 res: MedusaResponse22): Promise<void> {23 const { name, promo_id, tier_rules } = req.validatedBody24 25 const { result } = await createTierWorkflow(req.scope).run({26 input: {27 name,28 promo_id: promo_id || null,29 tier_rules: tier_rules || [],30 },31 })32 33 res.json({ tier: result.tier })34}
First, you define a Zod schema that validates the request body.
Then, you export a POST function, which exposes a POST API route at /admin/tiers.
In the route handler function, you execute the createTierWorkflow by invoking it, passing it the Medusa container, then executing its run method.
You return the created tier in the response.
You'll test out this API route later when you customize the Medusa Admin dashboard.
c. Apply Validation Middleware#
To ensure incoming request bodies are validated, you need to apply a middleware.
To apply a middleware to the API route, create the file src/api/middlewares.ts with the following content:
1import {2 defineMiddlewares,3 validateAndTransformBody,4} from "@medusajs/framework/http"5import { CreateTierSchema } from "./admin/tiers/route"6 7export default defineMiddlewares({8 routes: [9 {10 matcher: "/admin/tiers",11 methods: ["POST"],12 middlewares: [validateAndTransformBody(CreateTierSchema)],13 },14 ],15})
You apply the validateAndTransformBody middleware to the POST route of the /admin/tiers 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.
Step 5: Retrieve Tiers API Route#
In this step, you'll add an API route that retrieves tiers. You'll use this API route later when you customize the Medusa Admin to display tiers in the Medusa Admin.
To create the API route, add the following function to the src/api/admin/tiers/route.ts file:
1export async function GET(2 req: MedusaRequest,3 res: MedusaResponse4): Promise<void> {5 const query = req.scope.resolve("query")6 7 const { data: tiers, metadata } = await query.graph({8 entity: "tier",9 ...req.queryConfig,10 })11 12 res.json({13 tiers,14 count: metadata?.count || 0,15 offset: metadata?.skip || 0,16 limit: metadata?.take || 15,17 })18}
You export a GET route handler function, which will expose a GET API route at /admin/tiers.
In the route handler, you resolve Query from the Medusa container and use it to retrieve a list of tiers.
Notice that you spread the req.queryConfig object into the query.graph method. This allows clients to pass query parameters for pagination and configure returned fields. You'll learn how to set these configurations in a bit.
You return the list of tiers in the response.
You'll test out this API route later when you customize the Medusa Admin dashboard.
Apply Query Configurations Middleware#
Next, you need to apply a middleware that validates the query parameters passed to the request, and sets the default Query configurations.
In src/api/middlewares.ts, add the following imports at the top of the file:
Then, add the following object to the routes array passed to defineMiddlewares:
You apply the validateAndTransformQuery middleware to GET requests sent to the /admin/tiers route, passing it the createFindParams utility function to create a schema that validates common query parameters like limit, offset, fields, and order.
You set the following configurations:
isList: Set totrueto indicate that the API route returns a list of records.defaults: An array of fields to return by default if the client doesn't specify any fields in the request.
Step 6: Manage Customer Tiers in Medusa Admin#
In this step, you'll customize the Medusa Admin to display and create tiers.
The Medusa Admin dashboard is customizable, allowing you to insert widgets into existing pages, or create new pages.
a. Initialize JS SDK#
To send requests to the Medusa server, you'll use the JS SDK. 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:
Learn more about the initialization options in the JS SDK reference.
b. Tiers UI Route#
Next, you'll create a UI route that displays the list of tiers in the Medusa Admin.
A UI route is a React component that specifies the content to be shown in a new page in the Medusa Admin dashboard.
To create the UI route, create the file src/admin/routes/tiers/page.tsx with the following content:
15import { sdk } from "../../lib/sdk"16 17export type Tier = {18 id: string19 name: string20 promotion: {21 id: string22 code: string23 } | null24 tier_rules: Array<{25 id: string26 min_purchase_value: number27 currency_code: string28 }>29}30 31type TiersResponse = {32 tiers: Tier[]33 count: number34 offset: number35 limit: number36}37 38const columnHelper = createDataTableColumnHelper<Tier>()39 40const columns = [41 columnHelper.accessor("name", {42 header: "Name",43 enableSorting: true,44 }),45 columnHelper.accessor("promotion", {46 header: "Promotion",47 cell: ({ getValue }) => {48 const promotion = getValue()49 return promotion ? <Link to={`/promotions/${promotion.id}`}>{promotion.code}</Link> : "-"50 },51 }),52]53 54const TiersPage = () => {55 const navigate = useNavigate()56 const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)57 const limit = 1558 const [pagination, setPagination] = useState<DataTablePaginationState>({59 pageSize: limit,60 pageIndex: 0,61 })62 63 const offset = useMemo(() => {64 return pagination.pageIndex * limit65 }, [pagination])66 67 const { data, isLoading } = useQuery({68 queryFn: () =>69 sdk.client.fetch<TiersResponse>("/admin/tiers", {70 method: "GET",71 query: {72 limit,73 offset,74 },75 }),76 queryKey: ["tiers", "list", limit, offset],77 })78 79 const tiers = data?.tiers || []80 81 const table = useDataTable({82 columns,83 data: tiers,84 getRowId: (tier) => tier.id,85 rowCount: data?.count || 0,86 isLoading,87 pagination: {88 state: pagination,89 onPaginationChange: setPagination,90 },91 onRowClick: (_event, row) => {92 // TODO navigate to the tier details page93 },94 })95 96 return (97 <Container className="divide-y p-0">98 <DataTable instance={table}>99 <DataTable.Toolbar className="flex items-center justify-between px-6 py-4">100 <Heading level="h1">Customer Tiers</Heading>101 <Button onClick={() => setIsCreateModalOpen(true)}>102 Create Tier103 </Button>104 </DataTable.Toolbar>105 <DataTable.Table />106 <DataTable.Pagination />107 </DataTable>108 {/* TODO show create tier modal */}109 </Container>110 )111}112 113export const config = defineRouteConfig({114 label: "Customer Tiers",115 icon: UserGroup,116})117 118export default TiersPage
A UI route file must export a React component as the default export. This component is rendered when the user navigates to the UI route. It can also export a route configuration object that defines the UI route's label and icon in the sidebar.
In the component, you:
- Define state variables to configure pagination.
- Fetch the tiers using the JS SDK and Tanstack Query. By using Tanstack Query, you can easily manage the data fetching state, handle pagination, and cache the data.
- Create a DataTable instance from Medusa UI. You pass the columns, data, and pagination configurations to the hook.
- Render the DataTable component with a toolbar and pagination controls.
c. Create Tier Modal Component#
Next, you'll create a component that shows a form to create a tier in a modal.
Create the file src/admin/components/create-tier-modal.tsx with the following content:
8import { Tier } from "../routes/tiers/page"9 10type CreateTierFormData = {11 name: string12 promo_id: string | null13 tier_rules: Array<{14 min_purchase_value: number15 currency_code: string16 }>17}18 19type CreateTierModalProps = {20 open: boolean21 onOpenChange: (open: boolean) => void22}23 24export const CreateTierModal = ({ open, onOpenChange }: CreateTierModalProps) => {25 const navigate = useNavigate()26 const queryClient = useQueryClient()27 const [tierRules, setTierRules] = useState<{28 currency_code: string29 min_purchase_value: number30 }[]>([])31 32 const form = useForm<CreateTierFormData>({33 defaultValues: {34 name: "",35 promo_id: null,36 tier_rules: [],37 },38 })39 40 // TODO add queries and mutations41}
You define a component that receives the modal's open state and a function to close it.
In the component, so far you define necessary variables and initialize the form.
Next, you'll define Tanstack queries and mutations to retrieve form data and create the tier. Replace the TODO with the following:
1const { data: promotionsData } = useQuery({2 queryFn: () => sdk.admin.promotion.list(),3 queryKey: ["promotions", "list"],4})5 6const { data: storeData } = useQuery({7 queryFn: () =>8 sdk.admin.store.list({9 fields: "id,supported_currencies.*,supported_currencies.currency.*",10 }),11 queryKey: ["store"],12})13 14const createTierMutation = useMutation({15 mutationFn: async (data: CreateTierFormData) => {16 return await sdk.client.fetch<{ tier: Tier }>("/admin/tiers", {17 method: "POST",18 body: data,19 })20 },21 onSuccess: (data: { tier: Tier }) => {22 queryClient.invalidateQueries({ queryKey: ["tiers"] })23 form.reset()24 setTierRules([])25 onOpenChange(false)26 // TODO navigate to the new tier page27 toast.success("Success", {28 description: "Tier created successfully",29 position: "top-right",30 })31 },32 onError: (error) => {33 toast.error("Error", {34 description: error.message,35 position: "top-right",36 })37 },38})39 40// TODO and function handlers
You retrieve the promotions and store data to populate the form with promotions and supported currencies for rules.
You also define a mutation to create a tier using the useMutation hook from Tanstack Query.
Next, you'll add functions to handle form submissions and other actions. Replace the TODO with the following:
1const handleSubmit = form.handleSubmit((data) => {2 createTierMutation.mutate({3 ...data,4 tier_rules: tierRules,5 })6})7 8const promotions = promotionsData?.promotions || []9const store = storeData?.stores?.[0]10const supportedCurrencies = store?.supported_currencies || []11 12const getAvailableCurrencies = () => {13 const usedCurrencies = new Set(tierRules.map((rule) => rule.currency_code))14 return supportedCurrencies.filter((sc) => !usedCurrencies.has(sc.currency_code))15}16 17const addTierRule = () => {18 const availableCurrencies = getAvailableCurrencies()19 if (availableCurrencies.length > 0) {20 const firstCurrency = availableCurrencies[0].currency_code21 setTierRules([22 ...tierRules,23 {24 currency_code: firstCurrency,25 min_purchase_value: 0,26 },27 ])28 }29}30 31const removeTierRule = (index: number) => {32 setTierRules(tierRules.filter((_, i) => i !== index))33}34 35const updateTierRule = (index: number, field: "currency_code" | "min_purchase_value", value: string | number) => {36 const updated = [...tierRules]37 updated[index] = {38 ...updated[index],39 [field]: value,40 }41 setTierRules(updated)42}43 44// TODO add return statement
You define the following functions:
handleSubmit: Handles form submissions by calling thecreateTierMutation, passing it the form data and tier rules.getAvailableCurrencies: Returns the available currencies that haven't been used yet in the tier rules.addTierRule: Adds a new tier rule to the form.removeTierRule: Removes a tier rule from the form.updateTierRule: Updates a tier rule in the form.
Finally, replace the TODO with the following return statement to render the modal:
1return (2 <FocusModal open={open} onOpenChange={onOpenChange}>3 <FocusModal.Content>4 <FormProvider {...form}>5 <form onSubmit={handleSubmit} className="flex h-full flex-col overflow-hidden">6 <FocusModal.Header>7 <div className="flex items-center justify-between">8 <Heading level="h1">Create Tier</Heading>9 </div>10 </FocusModal.Header>11 <FocusModal.Body className="flex flex-1 flex-col overflow-y-auto">12 <div className="mx-auto flex w-full max-w-[720px] flex-col gap-y-8 px-2 py-16">13 <div className="flex flex-col gap-y-4">14 <Controller15 control={form.control}16 name="name"17 rules={{ required: "Name is required" }}18 render={({ field }) => (19 <div className="flex flex-col gap-y-2">20 <Label size="small" weight="plus">21 Name22 </Label>23 <Input {...field} placeholder="e.g., Bronze, Silver, Gold" />24 </div>25 )}26 />27 28 <Controller29 control={form.control}30 name="promo_id"31 render={({ field }) => (32 <div className="flex flex-col gap-y-2">33 <Label size="small" weight="plus">34 Promotion (Optional)35 </Label>36 <Select37 value={field.value || ""}38 onValueChange={(value) => field.onChange(value || null)}39 >40 <Select.Trigger>41 <Select.Value placeholder="Select a promotion" />42 </Select.Trigger>43 <Select.Content>44 {promotions.map((promo) => (45 <Select.Item key={promo.id} value={promo.id}>46 {promo.code}47 </Select.Item>48 ))}49 </Select.Content>50 </Select>51 </div>52 )}53 />54 55 <div className="flex flex-col gap-y-4">56 <div className="flex items-center justify-between">57 <Label size="small" weight="plus">58 Tier Rules59 </Label>60 <Button61 type="button"62 variant="secondary"63 size="small"64 onClick={addTierRule}65 disabled={getAvailableCurrencies().length === 0}66 >67 Add Rule68 </Button>69 </div>70 71 {tierRules.length === 0 && (72 <div className="text-sm text-gray-500">73 No tier rules added. Click "Add Rule" to add a rule for a currency.74 </div>75 )}76 77 {tierRules.map((rule, index) => (78 <div key={index} className="flex items-end gap-x-2 rounded-lg border p-4">79 <div className="flex flex-1 flex-col gap-y-2">80 <Label size="small">Currency</Label>81 <Select82 value={rule.currency_code}83 onValueChange={(value) => updateTierRule(index, "currency_code", value)}84 >85 <Select.Trigger>86 <Select.Value placeholder="Select currency" />87 </Select.Trigger>88 <Select.Content>89 {supportedCurrencies90 .filter((sc) => {91 // Allow current selection or currencies not used in other rules92 return (93 sc.currency_code === rule.currency_code ||94 !tierRules.some(95 (r, i) => i !== index && r.currency_code === sc.currency_code96 )97 )98 })99 .map((sc) => (100 <Select.Item key={sc.currency_code} value={sc.currency_code}>101 {sc.currency.code.toUpperCase()} - {sc.currency.name}102 </Select.Item>103 ))}104 </Select.Content>105 </Select>106 </div>107 <div className="flex flex-1 flex-col gap-y-2">108 <Label size="small">Minimum Purchase Value</Label>109 <Input110 type="number"111 min="0"112 step="0.01"113 value={rule.min_purchase_value}114 onChange={(e) =>115 updateTierRule(index, "min_purchase_value", parseFloat(e.target.value) || 0)116 }117 />118 </div>119 <IconButton120 type="button"121 variant="transparent"122 size="small"123 onClick={() => removeTierRule(index)}124 >125 <Trash />126 </IconButton>127 </div>128 ))}129 </div>130 </div>131 </div>132 </FocusModal.Body>133 <FocusModal.Footer>134 <div className="flex items-center gap-x-2">135 <FocusModal.Close asChild>136 <Button variant="secondary" size="small">137 Cancel138 </Button>139 </FocusModal.Close>140 <Button type="submit" size="small" isLoading={createTierMutation.isPending}>141 Create142 </Button>143 </div>144 </FocusModal.Footer>145 </form>146 </FormProvider>147 </FocusModal.Content>148 </FocusModal>149)
You display a FocusModal from Medusa UI. In the modal, you render a form with the following fields:
- Name: The name of the tier.
- Promotion: A select input to choose a promotion that's associated with the tier.
- Tier Rules: A list of inputs to specify the minimum purchase value required in a specific currency to qualify for the tier.
d. Show Create Tier Modal#
Next, you'll show the create tier modal when the user clicks the "Create Tier" button in the tiers page.
First, add the following import at the top of src/admin/routes/tiers/page.tsx:
Next, replace the TODO in the TiersPage component's return statement with the following:
You display the create tier modal when the user clicks the "Create Tier" button in the tiers page.
Test Customer Tiers in Medusa Admin#
To test out the customer tiers in the Medusa Admin, start the Medusa application:
Then, open the Medusa Admin dashboard at http://localhost:9000/app and log in using the credentials you set up earlier.
You'll find a new sidebar item labeled "Customer Tiers." Click on it to view the list of tiers.

Before creating a tier, you should create a promotion.
Then, on the Customer Tiers page, click the "Create Tier" button to open the create tier modal.
In the modal, enter the tier's name, select the promotion you created, and add tier rules for the currencies in your store. Once you're done, click the "Create" button to create the tier.

You'll see the new tier in the list of tiers. Later, you'll add a page to view and edit a single tier's details.
Step 7: Retrieve Tier API Route#
In this step, you'll add an API route that retrieves a tier.
To create the API route, create the file src/api/admin/tiers/[id]/route.ts with the following content:
1import {2 MedusaRequest,3 MedusaResponse,4} from "@medusajs/framework/http"5 6export async function GET(7 req: MedusaRequest,8 res: MedusaResponse9): Promise<void> {10 const query = req.scope.resolve("query")11 const { id } = req.params12 13 const { data: tiers } = await query.graph({14 entity: "tier",15 filters: {16 id,17 },18 ...req.queryConfig,19 }, {20 throwIfKeyNotFound: true,21 })22 23 res.json({ tier: tiers[0] })24}
You export a GET route handler function, which will expose a GET API route at /admin/tiers/:id.
In the route handler, you resolve Query from the Medusa container and use it to retrieve the tier with the given ID.
You return the tier in the response.
You'll test out this API route later when you customize the Medusa Admin dashboard.
Apply Query Configurations Middleware#
Next, you need to apply a middleware that validates the query parameters passed to the request, and sets the default Query configurations.
In src/api/middlewares.ts, add the following object to the routes array passed to defineMiddlewares:
1export default defineMiddlewares({2 routes: [3 // ...4 {5 matcher: "/admin/tiers/:id",6 methods: ["GET"],7 middlewares: [8 validateAndTransformQuery(createFindParams(), {9 isList: false,10 defaults: ["id", "name", "promotion.id", "promotion.code", "tier_rules.*"],11 }),12 ],13 },14 ],15})
Similar to before, you define the query configurations for the GET request to the /admin/tiers/:id route.
Step 8: Update Tier#
In this step, you'll add the functionality to update tiers. This includes creating a workflow to update a tier and an API route that executes it.
a. Update Tier Workflow#
The workflow to update a tier has the following steps:
Workflow hook
Step conditioned by when
View step details
Medusa provides the useQueryGraphStep out-of-the-box, and you've implemented the createTierRulesStep. You'll create the other steps before creating the workflow.
Update Tier Step
The updateTierStep updates a tier.
To create the step, create the file src/workflows/steps/update-tier.ts with the following content:
1import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"2import { TIER_MODULE } from "../../modules/tier"3import TierModuleService from "../../modules/tier/service"4 5export type UpdateTierStepInput = {6 id: string7 name: string8 promo_id: string | null9}10 11export const updateTierStep = createStep(12 "update-tier",13 async (input: UpdateTierStepInput, { container }) => {14 const tierModuleService: TierModuleService = container.resolve(TIER_MODULE)15 16 const originalTier = await tierModuleService.retrieveTier(input.id)17 18 const tier = await tierModuleService.updateTiers(input)19 20 return new StepResponse(tier, originalTier)21 },22 async (originalInput, { container }) => {23 if (!originalInput) {24 return25 }26 27 const tierModuleService = container.resolve(TIER_MODULE)28 29 await tierModuleService.updateTiers({30 id: originalInput.id,31 name: originalInput.name,32 promo_id: originalInput.promo_id,33 })34 }35)
The step receives the tier's ID and the details to update.
In the step function, you retrieve the original tier, then you update it. You pass the original tier details to the compensation function.
In the compensation function, you restore the original tier details if an error occurs during the workflow's execution.
Delete Tier Rules Step
The deleteTierRulesStep deletes tier rules.
To create the step, create the file src/workflows/steps/delete-tier-rules.ts with the following content:
1import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"2import { TIER_MODULE } from "../../modules/tier"3import TierModuleService from "../../modules/tier/service"4 5export type DeleteTierRulesStepInput = {6 ids: string[]7}8 9export const deleteTierRulesStep = createStep(10 "delete-tier-rules",11 async (input: DeleteTierRulesStepInput, { container }) => {12 const tierModuleService: TierModuleService = container.resolve(TIER_MODULE)13 14 // Get existing rules15 const existingRules = await tierModuleService.listTierRules({16 id: input.ids,17 })18 19 // Delete all rules20 await tierModuleService.deleteTierRules(input.ids)21 22 return new StepResponse(void 0, existingRules)23 },24 async (compensationData, { container }) => {25 if (!compensationData?.length) {26 return27 }28 29 const tierModuleService: TierModuleService = container.resolve(TIER_MODULE)30 // Restore deleted rules31 await tierModuleService.createTierRules(32 compensationData.map((rule) => ({33 tier_id: rule.tier_id,34 min_purchase_value: rule.min_purchase_value,35 currency_code: rule.currency_code,36 }))37 )38 }39)
The step receives the IDs of the tier rules to delete.
In the step function, you retrieve the existing rules, then you delete them. You pass the existing rules to the compensation function.
In the compensation function, you restore the existing rules if an error occurs during the workflow's execution.
Update Tier Workflow
You can now create the workflow that updates a tier.
Create the file src/workflows/update-tier.ts with the following content:
10import { createTierRulesStep } from "./steps/create-tier-rules"11 12export type UpdateTierWorkflowInput = {13 id: string14 name: string15 promo_id?: string | null16 tier_rules?: Array<{17 min_purchase_value: number18 currency_code: string19 }>20}21 22export const updateTierWorkflow = createWorkflow(23 "update-tier",24 (input: UpdateTierWorkflowInput) => {25 const { data: tiers } = useQueryGraphStep({26 entity: "tier",27 fields: ["tier_rules.*"],28 filters: {29 id: input.id,30 },31 options: {32 throwIfKeyNotFound: true,33 },34 })35 // Validate promotion if provided36 when({ input }, (data) => !!data.input.promo_id)37 .then(() => {38 useQueryGraphStep({39 entity: "promotion",40 fields: ["id"],41 filters: {42 id: input.promo_id!,43 },44 options: {45 throwIfKeyNotFound: true,46 },47 }).config({ name: "retrieve-promotion" })48 })49 // Update the tier50 updateTierStep({51 id: input.id,52 name: input.name,53 promo_id: input.promo_id || null,54 })55 56 when({ input }, (data) => {57 return !!data.input.tier_rules?.length58 }).then(() => {59 const ids = transform({60 tiers,61 }, (data) => {62 return (data.tiers[0].tier_rules?.map((rule) => rule?.id) || []) as string[]63 })64 deleteTierRulesStep({65 ids,66 })67 return createTierRulesStep({68 tier_id: input.id,69 tier_rules: input.tier_rules!,70 })71 })72 73 // Retrieve the updated tier with rules74 const { data: updatedTiers } = useQueryGraphStep({75 entity: "tier",76 fields: ["*", "tier_rules.*"],77 filters: {78 id: input.id,79 },80 }).config({ name: "updated-tier" })81 82 return new WorkflowResponse({83 tier: updatedTiers[0],84 })85 }86)
The workflow receives the details to update the tier.
In the workflow, you:
- Retrieve the tier to update using the
useQueryGraphStep. - Use
when-thento check if the promotion ID is provided. If so, useuseQueryGraphStepto validate that it exists. - Update the tier using the
updateTierStep. - If new tier rules are provided, you:
- delete the existing tier rules using the
deleteTierRulesStep. - Create the new tier rules using the
createTierRulesStep.
- delete the existing tier rules using the
- Retrieve the updated tier with rules using the
useQueryGraphStep.
Finally, you return a WorkflowResponse with the updated tier.
transform to prepare data based on execution values. Learn more in the Data Manipulation workflow documentation.b. Update Tier API Route#
Next, you'll create the API route that exposes the workflow's functionality to client applications.
In src/api/admin/tiers/[id]/route.ts, add the following imports at the top of the file:
Then, add the following at the end of the file:
1export const UpdateTierSchema = z.object({2 name: z.string(),3 promo_id: z.string().nullable(),4 tier_rules: z.array(z.object({5 min_purchase_value: z.number(),6 currency_code: z.string(),7 })),8})9 10type UpdateTierInput = z.infer<typeof UpdateTierSchema>11 12export async function POST(13 req: MedusaRequest<UpdateTierInput>,14 res: MedusaResponse15): Promise<void> {16 const { id } = req.params17 const { name, promo_id, tier_rules } = req.validatedBody18 19 const { result } = await updateTierWorkflow(req.scope).run({20 input: {21 id,22 name,23 promo_id: promo_id !== undefined ? promo_id : null,24 tier_rules: tier_rules || [],25 },26 })27 28 res.json({ tier: result.tier })29}
You define a Zod schema that validates the request body.
Then, you export a POST function, which exposes a POST API route at /admin/tiers/:id.
In the route handler, you execute the updateTierWorkflow and return the updated tier in the response.
You'll test out the API route later when you customize the Medusa Admin dashboard.
c. Apply Validation Middleware
Next, you'll apply a validation middleware to the API route.
In src/api/middlewares.ts, add the following import at the top of the file:
Then, add a new route object passed to the array in defineMiddlewares:
You apply the validateAndTransformBody middleware to the POST route of the /admin/tiers/:id 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.
Step 9: Retrieve Customers in Tier API Route#
In this step, you'll add an API route that retrieves customers in a tier. This will be useful to show the customers in the tier's page on the Medusa Admin.
a. Retrieve Customers in Tier API Route#
To create the API route, create the file src/api/admin/tiers/[id]/customers/route.ts with the following content:
1import {2 MedusaRequest,3 MedusaResponse,4} from "@medusajs/framework/http"5import { ContainerRegistrationKeys } from "@medusajs/framework/utils"6 7export async function GET(8 req: MedusaRequest,9 res: MedusaResponse10): Promise<void> {11 const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)12 const { id } = req.params13 14 // Query customers linked to this tier15 const { data: customers, metadata } = await query.index({16 entity: "customer",17 filters: {18 tier: {19 id,20 },21 },22 ...req.queryConfig,23 })24 25 res.json({26 customers,27 count: metadata?.estimate_count || 0,28 offset: metadata?.skip || 0,29 limit: metadata?.take || 15,30 })31}
You export a GET function, which exposes a GET API route at /admin/tiers/:id/customers.
In the route handler, you resolve Query from the Medusa container and use it to retrieve customers linked to the tier with the given ID.
Notice that you use the query.index method. This method is similar to query.graph but allows you to filter by linked records using the Index Module.
You return the customers in the response.
You'll test out this API route later when you customize the Medusa Admin dashboard.
b. Apply Query Configurations Middleware#
Next, you need to apply a middleware that validates the query parameters passed to the request, and sets the default Query configurations.
In src/api/middlewares.ts, add the following object to the routes array passed to defineMiddlewares:
You apply the validateAndTransformQuery middleware to the GET route of the /admin/tiers/:id/customers path, passing it the createFindParams utility function to create a schema that validates common query parameters like limit, offset, fields, and order.
You set the following configurations:
isList: Set totrueto indicate that the API route returns a list of records.defaults: An array of fields to return by default if the client doesn't specify any fields in the request.
c. Install Index Module#
The Index Module is a tool for performing high-performance queries across modules, such as filtering linked modules.
The Index Module is currently experimental, so you need to install and configure it manually.
To install the Index Module, run the following command in your Medusa application's directory:
Then, add the following to your Medusa application's configuration file:
Next, run the migrations to create the necessary tables for the Index Module in your database:
Lastly, start the Medusa application to ingest the data into the Index Module:
You can now use the Index Module to filter customers by their tier. You'll test out the API route when you customize the Medusa Admin dashboard in the next step.
Step 10: Tier Details UI Route#
In this step, you'll create a UI route that displays the details of a tier.
The UI route is composed of three sections:
- Tier Details Section: This also includes a form to edit the tier's details.
- Tier Rules Table
- Tier Customers Table
You'll create the components for each section first, then you'll create the UI route.
a. Edit Tier Drawer Component#
You'll first create a drawer component that displays a form to edit the tier's details. You'll then display the component in the Tier Details Section.
To create the drawer component, create the file src/admin/components/edit-tier-drawer.tsx with the following content:
1import { Drawer, Heading, Label, Input, Button, Select, IconButton, toast } from "@medusajs/ui"2import { useForm, Controller, FormProvider } from "react-hook-form"3import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"4import { sdk } from "../lib/sdk"5import { useState, useEffect } from "react"6import { Tier } from "../routes/tiers/page"7import { Trash } from "@medusajs/icons"8 9type EditTierFormData = {10 name: string11 promo_id: string | null12 tier_rules: Array<{13 min_purchase_value: number14 currency_code: string15 }>16}17 18type EditTierDrawerProps = {19 tier: Tier | undefined20}21 22export const EditTierDrawer = ({ tier }: EditTierDrawerProps) => {23 const queryClient = useQueryClient()24 const [open, setOpen] = useState(false)25 const [tierRules, setTierRules] = useState<{26 currency_code: string27 min_purchase_value: number28 }[]>([])29 30 const form = useForm<EditTierFormData>({31 defaultValues: {32 name: "",33 promo_id: null,34 tier_rules: [],35 },36 })37 38 // TODO add queries and mutations39}
You define a component that receives the tier to edit.
In the component, you define the form and the necessary variables.
Next, you'll add queries to retrieve promotions and store data, and a mutation to update the tier. Replace the TODO with the following:
1const { data: promotionsData } = useQuery({2 queryFn: () => sdk.admin.promotion.list(),3 queryKey: ["promotions", "list"],4 enabled: open,5})6 7const { data: storeData } = useQuery({8 queryFn: () =>9 sdk.admin.store.list({10 fields: "id,supported_currencies.*,supported_currencies.currency.*",11 }),12 queryKey: ["store"],13 enabled: open,14})15 16const updateTierMutation = useMutation({17 mutationFn: async (data: EditTierFormData) => {18 if (!tier) {return}19 return await sdk.client.fetch(`/admin/tiers/${tier.id}`, {20 method: "POST",21 body: data,22 })23 },24 onSuccess: () => {25 queryClient.invalidateQueries({ queryKey: ["tier", tier?.id] })26 queryClient.invalidateQueries({ queryKey: ["tiers"] })27 setOpen(false)28 toast.success("Success", {29 description: "Tier updated successfully",30 position: "top-right",31 })32 },33 onError: (error) => {34 toast.error("Error", {35 description: error.message,36 position: "top-right",37 })38 },39})40 41// TODO initialize form on component mount
You retrieve the promotions and store data to populate the form with promotions and supported currencies for rules.
You also define a mutation to update a tier using the useMutation hook from Tanstack Query.
Next, you'll reset form data when the drawer is opened or closed. Replace the TODO with the following:
1useEffect(() => {2 if (tier && open) {3 form.reset({4 name: tier.name,5 promo_id: tier.promotion?.id || null,6 tier_rules: tier.tier_rules || [],7 })8 setTierRules(9 tier.tier_rules?.map((rule) => ({10 currency_code: rule.currency_code,11 min_purchase_value: rule.min_purchase_value,12 })) || []13 )14 }15}, [tier, open, form])16 17// TODO add function handlers
You reset the form data when the drawer is opened.
Next, you'll add functions to handle form submissions and other actions. Replace the TODO with the following:
1const handleSubmit = form.handleSubmit((data) => {2 updateTierMutation.mutate({3 ...data,4 tier_rules: tierRules,5 })6})7 8const promotions = promotionsData?.promotions || []9const store = storeData?.stores?.[0]10const supportedCurrencies = store?.supported_currencies || []11 12const getAvailableCurrencies = () => {13 const usedCurrencies = new Set(tierRules.map((rule) => rule.currency_code))14 return supportedCurrencies.filter((sc) => !usedCurrencies.has(sc.currency_code))15}16 17const addTierRule = () => {18 const availableCurrencies = getAvailableCurrencies()19 if (availableCurrencies.length > 0) {20 const firstCurrency = availableCurrencies[0].currency_code21 setTierRules([22 ...tierRules,23 {24 currency_code: firstCurrency,25 min_purchase_value: 0,26 },27 ])28 }29}30 31const removeTierRule = (index: number) => {32 setTierRules(tierRules.filter((_, i) => i !== index))33}34 35const updateTierRule = (36 index: number,37 field: "currency_code" | "min_purchase_value",38 value: string | number39) => {40 const updated = [...tierRules]41 updated[index] = {42 ...updated[index],43 [field]: value,44 }45 setTierRules(updated)46}47 48// TODO add return statement
You define the following functions:
handleSubmit: Handles form submissions by calling theupdateTierMutationmutation with the form data and tier rules.getAvailableCurrencies: Returns the available currencies that haven't been used yet in the tier rules.addTierRule: Adds a new tier rule to the form.removeTierRule: Removes a tier rule from the form.updateTierRule: Updates a tier rule in the form.
Finally, replace the TODO with the following return statement to render the drawer:
1return (2 <Drawer open={open} onOpenChange={setOpen}>3 <Drawer.Trigger asChild>4 <Button variant="secondary" size="small">5 Edit6 </Button>7 </Drawer.Trigger>8 <Drawer.Content>9 <FormProvider {...form}>10 <form onSubmit={handleSubmit} className="flex flex-1 flex-col overflow-hidden">11 <Drawer.Header>12 <Heading level="h1">Edit Tier</Heading>13 </Drawer.Header>14 <Drawer.Body className="flex max-w-full flex-1 flex-col gap-y-8 overflow-y-auto">15 <Controller16 control={form.control}17 name="name"18 rules={{ required: "Name is required" }}19 render={({ field }) => (20 <div className="flex flex-col space-y-2">21 <Label size="small" weight="plus">22 Name23 </Label>24 <Input {...field} placeholder="e.g., Bronze, Silver, Gold" />25 </div>26 )}27 />28 29 <Controller30 control={form.control}31 name="promo_id"32 render={({ field }) => (33 <div className="flex flex-col space-y-2">34 <Label size="small" weight="plus">35 Promotion (Optional)36 </Label>37 <Select38 value={field.value || ""}39 onValueChange={(value) => field.onChange(value || null)}40 >41 <Select.Trigger>42 <Select.Value placeholder="Select a promotion" />43 </Select.Trigger>44 <Select.Content>45 {promotions.map((promo) => (46 <Select.Item key={promo.id} value={promo.id}>47 {promo.code}48 </Select.Item>49 ))}50 </Select.Content>51 </Select>52 </div>53 )}54 />55 56 <div className="flex flex-col gap-y-4">57 <div className="flex items-center justify-between">58 <Label size="small" weight="plus">59 Tier Rules60 </Label>61 <Button62 type="button"63 variant="secondary"64 size="small"65 onClick={addTierRule}66 disabled={getAvailableCurrencies().length === 0}67 >68 Add Rule69 </Button>70 </div>71 72 {tierRules.length === 0 && (73 <div className="text-sm text-gray-500">74 No tier rules added. Click "Add Rule" to add a rule for a currency.75 </div>76 )}77 78 {tierRules.map((rule, index) => (79 <div key={index} className="flex items-end gap-x-2 rounded-lg border p-4">80 <div className="flex flex-1 flex-col gap-y-2">81 <Label size="small">Currency</Label>82 <Select83 value={rule.currency_code}84 onValueChange={(value) => updateTierRule(index, "currency_code", value)}85 >86 <Select.Trigger>87 <Select.Value />88 </Select.Trigger>89 <Select.Content>90 {supportedCurrencies91 .filter((sc) => {92 return (93 sc.currency_code === rule.currency_code ||94 !tierRules.some(95 (r, i) => i !== index && r.currency_code === sc.currency_code96 )97 )98 })99 .map((sc) => (100 <Select.Item key={sc.currency_code} value={sc.currency_code}>101 {sc.currency.code.toUpperCase()} - {sc.currency.name}102 </Select.Item>103 ))}104 </Select.Content>105 </Select>106 </div>107 <div className="flex flex-1 flex-col gap-y-2">108 <Label size="small">Minimum Purchase Value</Label>109 <Input110 type="number"111 min="0"112 step="0.01"113 value={rule.min_purchase_value}114 onChange={(e) =>115 updateTierRule(index, "min_purchase_value", parseFloat(e.target.value) || 0)116 }117 />118 </div>119 <IconButton120 type="button"121 variant="transparent"122 size="small"123 onClick={() => removeTierRule(index)}124 >125 <Trash />126 </IconButton>127 </div>128 ))}129 </div>130 </Drawer.Body>131 <Drawer.Footer>132 <div className="flex items-center justify-end gap-x-2">133 <Drawer.Close asChild>134 <Button size="small" variant="secondary">135 Cancel136 </Button>137 </Drawer.Close>138 <Button size="small" type="submit" isLoading={updateTierMutation.isPending}>139 Save140 </Button>141 </div>142 </Drawer.Footer>143 </form>144 </FormProvider>145 </Drawer.Content>146 </Drawer>147)
You display a Drawer from Medusa UI. In the drawer, you render a form with the following fields:
- Name: The name of the tier.
- Promotion: A select input to choose a promotion that's associated with the tier.
- Tier Rules: A list of inputs to specify the minimum purchase value required in a specific currency to qualify for the tier.
b. Tier Details Section Component#
Next, you'll create a component that displays the details of a tier, with a button to open the edit tier drawer.
To create the component, create the file src/admin/components/tier-details-section.tsx with the following content:
1import { Code, Container, Heading, Text } from "@medusajs/ui"2import { Link } from "react-router-dom"3import { Tier } from "../routes/tiers/page"4import { EditTierDrawer } from "./edit-tier-drawer"5 6type TierDetailsSectionProps = {7 tier: Tier | undefined8}9 10export const TierDetailsSection = ({ tier }: TierDetailsSectionProps) => {11 return (12 <Container className="divide-y p-0">13 <div className="flex items-center justify-between px-6 py-4">14 <Heading level="h1">Tier Details</Heading>15 <div className="flex items-center gap-x-2">16 <EditTierDrawer tier={tier} />17 </div>18 </div>19 <div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">20 <Text size="small" weight="plus" leading="compact">21 Name22 </Text>23 24 <Text25 size="small"26 leading="compact"27 className="whitespace-pre-line text-pretty"28 >29 {tier?.name ?? "-"}30 </Text>31 </div>32 <div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">33 <Text size="small" weight="plus" leading="compact">34 Promotion35 </Text>36 37 {tier?.promotion && (38 <Link to={`/promotions/${tier.promotion.id}`}>39 <Code>{tier.promotion.code}</Code>40 </Link>41 )}42 </div>43 </Container>44 )45}
You display the tier's name and a link to its associated promotion. You also display a button to open the edit tier drawer.
c. Tier Rules Table Component#
Next, you'll create a component that displays the tier rules in a table.
To create the component, create the file src/admin/components/tier-rules-table.tsx with the following content:
1import { Heading, DataTable, createDataTableColumnHelper, useDataTable, Container } from "@medusajs/ui"2import { Tier } from "../routes/tiers/page"3 4type TierRulesTableProps = {5 tierRules: Tier["tier_rules"] | undefined6}7 8type TierRule = {9 id: string10 currency_code: string11 min_purchase_value: number12}13 14const columnHelper = createDataTableColumnHelper<TierRule>()15 16const columns = [17 columnHelper.accessor("currency_code", {18 header: "Currency",19 cell: ({ getValue }) => getValue().toUpperCase(),20 }),21 columnHelper.accessor("min_purchase_value", {22 header: "Minimum Purchase Value",23 }),24]25 26export const TierRulesTable = ({ tierRules }: TierRulesTableProps) => {27 const rules = tierRules || []28 29 const table = useDataTable({30 columns,31 data: rules,32 getRowId: (rule) => rule.id,33 rowCount: rules.length,34 isLoading: false,35 })36 37 return (38 <Container className="divide-y p-0">39 <DataTable instance={table}>40 <DataTable.Toolbar className="flex items-center justify-between px-6 py-4">41 <Heading level="h2">42 Tier Rules43 </Heading>44 </DataTable.Toolbar>45 <DataTable.Table />46 </DataTable>47 </Container>48 )49}
The component receives the tier rules to display.
In the component, you display the tier rules in a DataTable. The table shows the currency code and the minimum purchase value required to qualify for the tier.
d. Tier Customers Table Component#
Next, you'll create a component that displays the customers in a tier in a table.
To create the component, create the file src/admin/components/tier-customers-table.tsx with the following content:
1import { Heading, DataTable, createDataTableColumnHelper, useDataTable, Container, DataTablePaginationState } from "@medusajs/ui"2import { sdk } from "../lib/sdk"3import { useQuery } from "@tanstack/react-query"4import { useMemo, useState } from "react"5 6type TierCustomersTableProps = {7 tierId: string8}9 10type Customer = {11 id: string12 email: string13 first_name: string | null14 last_name: string | null15}16 17type CustomersResponse = {18 customers: Customer[]19 count: number20 offset: number21 limit: number22}23 24const columnHelper = createDataTableColumnHelper<Customer>()25 26const columns = [27 columnHelper.accessor("email", {28 header: "Email",29 }),30 columnHelper.accessor("first_name", {31 header: "Name",32 cell: ({ row }) => {33 const customer = row.original34 return customer.first_name || customer.last_name35 ? `${customer.first_name || ""} ${customer.last_name || ""}`.trim()36 : "-"37 },38 }),39]40 41export const TierCustomersTable = ({ tierId }: TierCustomersTableProps) => {42 const limit = 1543 const [pagination, setPagination] = useState<DataTablePaginationState>({44 pageSize: limit,45 pageIndex: 0,46 })47 48 const offset = useMemo(() => {49 return pagination.pageIndex * limit50 }, [pagination])51 52 const { data: customersData, isLoading: customersLoading } = useQuery({53 queryFn: () =>54 sdk.client.fetch<CustomersResponse>(`/admin/tiers/${tierId}/customers`, {55 method: "GET",56 query: {57 limit,58 offset,59 },60 }),61 queryKey: ["tier", tierId, "customers"],62 enabled: !!tierId,63 })64 const table = useDataTable({65 columns,66 data: customersData?.customers || [],67 getRowId: (customer) => customer.id,68 rowCount: customersData?.count || 0,69 isLoading: customersLoading,70 pagination: {71 state: pagination,72 onPaginationChange: setPagination,73 },74 })75 76 return (77 <Container className="divide-y p-0">78 <DataTable instance={table}>79 <DataTable.Toolbar className="flex items-center justify-between px-6 py-4">80 <Heading level="h2">81 Customers in this Tier82 </Heading>83 </DataTable.Toolbar>84 <DataTable.Table />85 <DataTable.Pagination />86 </DataTable>87 </Container>88 )89}
The component receives the tier ID to retrieve the customers.
In the component, you fetch the customers in the tier using the API route you created in the previous step.
You display the customers in a DataTable. The table shows the email and the name of the customers with pagination controls.
e. Tier Details UI Route#
Finally, you'll create the UI route that displays the details of a tier.
To create the UI route, create the file src/admin/routes/tiers/[id]/page.tsx with the following content:
1import { defineRouteConfig } from "@medusajs/admin-sdk"2import { useParams } from "react-router-dom"3import { useQuery } from "@tanstack/react-query"4import { sdk } from "../../../lib/sdk"5import { Tier } from "../page"6import { TierDetailsSection } from "../../../components/tier-details-section"7import { TierRulesTable } from "../../../components/tier-rules-table"8import { TierCustomersTable } from "../../../components/tier-customers-table"9 10type TierResponse = {11 tier: Tier12}13 14const TierDetailsPage = () => {15 const { id } = useParams()16 17 const { data: tierData } = useQuery({18 queryFn: () =>19 sdk.client.fetch<TierResponse>(`/admin/tiers/${id}`, {20 method: "GET",21 }),22 queryKey: ["tier", id],23 enabled: !!id,24 })25 26 const tier = tierData?.tier27 28 return (29 <>30 <TierDetailsSection tier={tier} />31 <TierRulesTable tierRules={tier?.tier_rules} />32 {tier?.id && <TierCustomersTable tierId={tier.id} />}33 </>34 )35}36 37export const config = defineRouteConfig({38 label: "Tier Details",39})40 41export default TierDetailsPage
The component retrieves the tier details using the API route you created in the previous step.
Then, you display the components of the different sections you created earlier.
f. Navigate to Tier Details Page#
Next, you'll navigate to the tier details page when the admin user clicks on a row in the tiers list, and after creating a tier.
In src/admin/routes/tiers/page.tsx, find the onRowClick handler and replace it with the following:
You navigate to the tier details page when the user clicks on a tier in the tiers list.
Next, you'll navigate to the tier details page after creating a tier.
In src/admin/components/create-tier-modal.tsx, find the TODO navigate to the new tier page comment and replace it with the following:
You navigate to the tier details page after creating a tier.
Test Customer Tiers in Medusa Admin#
To test out the customer tiers in the Medusa Admin, start the Medusa application:
Then, open the Medusa Admin dashboard at http://localhost:9000/app and log in using the credentials you set up earlier.
You'll find a new sidebar item labeled "Customer Tiers." Click on it to view the list of tiers.
Click on the tier you created earlier. This will open its details page where you can view the tier's details, its rules, and its customers.

To edit the tier's details, click the Edit button. This will open a drawer where you can edit the tier's name, its associated promotion, and its tier rules.

Step 11: Update Customer Tier on Order#
In this step, you'll add the logic to update a customer's tier when an order is placed. This requires creating:
- A method in the Tier Module's service to determine the qualifying tier based on a customer's purchase history.
- A workflow to determine a customer's tier based on their purchase history.
- A subscriber that listens to the order placement event and executes the workflow.
a. Determine Qualifying Tier Method#
To determine the qualifying tier based on the customer's purchase history, you'll add a method to the Tier Module's service.
In src/modules/tier/service.ts, add the following method to the TierModuleService class:
1class TierModuleService extends MedusaService({2 Tier,3 TierRule,4}) {5 async calculateQualifyingTier(6 currencyCode: string,7 purchaseValue: number8 ) {9 const rules = await this.listTierRules(10 {11 currency_code: currencyCode,12 }13 )14 15 if (!rules || rules.length === 0) {16 return null17 }18 19 const sortedRules = rules.sort(20 (a, b) => b.min_purchase_value - a.min_purchase_value21 )22 23 const qualifyingRule = sortedRules.find(24 (rule) => purchaseValue >= rule.min_purchase_value25 )26 27 return qualifyingRule?.tier?.id || null28 }29}
The calculateQualifyingTier method receives the currency code and the purchase value of a customer.
In the method, you:
- Retrieve the tier rules for the given currency code.
- Sort the rules by the minimum purchase value in ascending order.
- Find the tier whose minimum purchase value is less than or equal to the purchase value.
- Return the ID of the qualifying tier.
You'll use this method in the steps of the workflow to update the customer's tier.
b. Update Customer Tier on Order Workflow#
The workflow to update a customer's tier on order placement has the following steps:
Workflow hook
Step conditioned by when
View step details
You only need to create the validateCustomerStep and determineTierStep steps. Medusa provides the other steps out of the box.
Validate Customer Step
The validateCustomerStep validates that the customer is a registered customer.
To create the step, create the file src/workflows/steps/validate-customer.ts with the following content:
1import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"2import { MedusaError } from "@medusajs/framework/utils"3 4export type ValidateCustomerStepInput = {5 customer: any6}7 8export const validateCustomerStep = createStep(9 "validate-customer",10 async (input: ValidateCustomerStepInput, { container }) => {11 if (!input.customer) {12 throw new MedusaError(13 MedusaError.Types.NOT_FOUND,14 "Customer not found"15 )16 }17 18 if (!input.customer.has_account) {19 throw new MedusaError(20 MedusaError.Types.INVALID_DATA,21 "Customer must be registered to be assigned a tier"22 )23 }24 25 return new StepResponse(input.customer)26 }27)
The step receives the customer to validate.
In the step, you validate that the customer is defined and that it's registered based on its has_account property. Otherwise, you throw an error.
Determine Tier Step
The determineTierStep determines the appropriate tier based on the customer's purchase history.
To create the step, create the file src/workflows/steps/determine-tier.ts with the following content:
1import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"2import { TIER_MODULE } from "../../modules/tier"3import TierModuleService from "../../modules/tier/service"4 5export type DetermineTierStepInput = {6 currency_code: string7 purchase_value: number8}9 10export const determineTierStep = createStep(11 "determine-tier",12 async (input: DetermineTierStepInput, { container }) => {13 const tierModuleService: TierModuleService = container.resolve(TIER_MODULE)14 15 const qualifyingTier = await tierModuleService.calculateQualifyingTier(16 input.currency_code,17 input.purchase_value18 )19 20 return new StepResponse(qualifyingTier)21 }22)
The step receives the currency code and the purchase value of a customer.
In the step, you resolve the Tier Module's service from the Medusa container and call the calculateQualifyingTier method to determine the qualifying tier.
You return the ID of the qualifying tier.
Update Customer Tier on Order Workflow
You can create the workflow that updates a customer's tier on order placement.
To create the workflow, create the file src/workflows/update-customer-tier-on-order.ts with the following content:
15import { TIER_MODULE } from "../modules/tier"16 17type WorkflowInput = {18 order_id: string19}20 21export const updateCustomerTierOnOrderWorkflow = createWorkflow(22 "update-customer-tier-on-order",23 (input: WorkflowInput) => {24 // Get order details25 const { data: orders } = useQueryGraphStep({26 entity: "order",27 fields: ["id", "currency_code", "total", "customer.*", "customer.tier.*"],28 filters: {29 id: input.order_id,30 },31 options: {32 throwIfKeyNotFound: true,33 },34 })35 36 const validatedCustomer = validateCustomerStep({37 customer: orders[0].customer,38 })39 40 // Query completed orders for the customer in the same currency41 const { data: completedOrders } = useQueryGraphStep({42 entity: "order",43 fields: ["id", "total", "currency_code"],44 filters: {45 customer_id: validatedCustomer.id,46 currency_code: orders[0].currency_code,47 status: {48 $nin: [49 OrderStatus.CANCELED,50 OrderStatus.DRAFT,51 ],52 },53 },54 }).config({ name: "completed-orders" })55 56 // Calculate total purchase value using transform57 const purchasedValue = transform(58 { completedOrders },59 (data) => {60 return data.completedOrders.reduce(61 (sum: number, order: any) => sum + (order.total || 0),62 063 )64 }65 )66 67 // Determine appropriate tier68 const tierId = determineTierStep({69 currency_code: orders[0].currency_code as string,70 purchase_value: purchasedValue,71 })72 73 // Dismiss existing tier link if it exists74 // and the tier id is not the same as the tier id in the determine tier step75 when({ orders, tierId }, (data) => !!data.orders[0].customer?.tier?.id && data.tierId !== data.orders[0].customer?.tier?.id).then(76 () => {77 dismissRemoteLinkStep([78 {79 [TIER_MODULE]: { tier_id: orders[0].customer?.tier?.id as string },80 [Modules.CUSTOMER]: { customer_id: validatedCustomer.id },81 },82 ])83 }84 )85 86 // Create new tier link if tierId is provided87 when({ tierId, orders }, (data) => !!data.tierId && data.orders[0].customer?.tier?.id !== data.tierId).then(() => {88 createRemoteLinkStep([89 {90 [TIER_MODULE]: { tier_id: tierId },91 [Modules.CUSTOMER]: { customer_id: validatedCustomer.id },92 },93 ])94 })95 96 return new WorkflowResponse({97 customer_id: validatedCustomer.id,98 tier_id: tierId,99 })100 }101)
The workflow receives the order ID as input.
In the workflow, you:
- Retrieve the order details using the
useQueryGraphStep. - Validate the customer using the
validateCustomerStep. This will throw an error if the customer is not a registered customer, which will stop the workflow's execution. - Retrieve the customer's completed orders in the same currency using
useQueryGraphStep. - Calculate the total purchase value using
transform. - Determine the appropriate tier using
determineTierStep. - Dismiss the existing tier link if it exists and the customer's tier has changed, using
dismissRemoteLinkStep. - Create a new tier link if the customer's tier has changed and a new tier ID is provided, using
createRemoteLinkStep.
Finally, you return a WorkflowResponse with the customer ID and the tier ID.
c. Update Customer Tier on Order Subscriber#
Next, you'll create a subscriber that listens to the order placement event and executes the workflow.
A subscriber is an asynchronous function that runs in the background when specific events are emitted.
To create the subscriber, create the file src/subscribers/order-placed.ts with the following content:
1import {2 SubscriberArgs,3 SubscriberConfig,4} from "@medusajs/framework"5import { updateCustomerTierOnOrderWorkflow } from "../workflows/update-customer-tier-on-order"6 7export default async function orderPlacedHandler({8 event: { data },9 container,10}: SubscriberArgs<{ id: string }>) {11 const logger = container.resolve("logger")12 try {13 await updateCustomerTierOnOrderWorkflow(container).run({14 input: {15 order_id: data.id,16 },17 })18 } catch (error) {19 logger.error(error)20 }21}22 23export const config: SubscriberConfig = {24 event: "order.placed",25}
A subscriber file must export:
- An asynchronous subscriber function that executes whenever the associated event is triggered.
- A configuration object with an event property whose value is the event the subscriber is listening to, which is
order.placedin this case.
The subscriber function receives an object with the following properties:
event: An object holding the event's details. It has adataproperty, which is the event's data payload.container: The Medusa container. Use it to resolve modules' main services and other registered resources.
In the subscriber function, you resolve the logger from the Medusa container and execute the workflow. If an error occurs, you log it.
Test Update Customer Tier on Order#
To test the workflow and subscriber, you'll need to place an order using the Next.js Starter Storefront that you installed in the first step.
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-customer-tiers, you can find the storefront by going back to the parent directory and changing to the medusa-customer-tiers-storefront directory:
First, start the Medusa application by running the following command in the Medusa application's directory:
Then, start the Next.js Starter Storefront by running the following command in the storefront's directory:
Next:
- Open the Next.js Starter Storefront in your browser at
http://localhost:8000. - Click on "Account" in the navigation bar and create a new account.
- After you're logged in, add products to the cart and complete the checkout process.
- Make sure the order total is high enough to qualify for the next tier.
- After you place the order, open the Medusa Admin dashboard at
http://localhost:9000/appand log in. - Go to the Customer Tiers page and click on the tier that the customer should have been assigned to.
- You'll see the customer in the tier's customers list section.

Step 12: Apply Tier Promotion to Customer Carts#
In this step, you'll apply a customer's tier promotion whenever they update their cart if it's not already applied.
To build this feature, you need a workflow that applies the tier promotion to a cart and a subscriber that listens to the cart update event and executes the workflow.
a. Add Tier Promotion to Cart Workflow#
The workflow to add a customer's tier promotion to a cart has the following steps:
Workflow hook
Step conditioned by when
View step details
You only need to create the validateTierPromotionStep. Medusa provides the other steps and workflows out-of-the-box.
Validate Tier Promotion Step
The validateTierPromotionStep validates that the customer is registered and has a tier promotion.
To create the step, create the file src/workflows/steps/validate-tier-promotion.ts with the following content:
1import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"2 3export type ValidateTierPromotionStepInput = {4 customer: {5 has_account: boolean6 tier?: {7 promo_id?: string | null8 promotion?: {9 id?: string10 code?: string | null11 status?: string | null12 } | null13 } | null14 } | null15}16 17export const validateTierPromotionStep = createStep(18 "validate-tier-promotion",19 async (input: ValidateTierPromotionStepInput) => {20 if (!input.customer || !input.customer.has_account) {21 return new StepResponse(null)22 }23 24 const tier = input.customer.tier25 26 if (!tier?.promo_id || !tier.promotion || tier.promotion.status !== "active") {27 return new StepResponse({ promotion_code: null })28 }29 30 return new StepResponse({31 promotion_code: tier.promotion.code || null,32 })33 }34)
The step receives the customer to validate.
In the step, you return null if the customer is not registered or if it doesn't have a tier promotion. Otherwise, you return the promotion code.
Add Tier Promotion to Cart Workflow
You can now create the workflow that adds a customer's tier promotion to a cart.
To create the workflow, create the file src/workflows/add-tier-promotion-to-cart.ts with the following content:
9import { validateTierPromotionStep } from "./steps/validate-tier-promotion"10 11export type AddTierPromotionToCartWorkflowInput = {12 cart_id: string13}14 15export const addTierPromotionToCartWorkflow = createWorkflow(16 "add-tier-promotion-to-cart",17 (input: AddTierPromotionToCartWorkflowInput) => {18 // Get cart with customer, tier, and promotions19 const { data: carts } = useQueryGraphStep({20 entity: "cart",21 fields: [22 "id",23 "customer.id",24 "customer.has_account",25 "customer.tier.*",26 "customer.tier.promotion.id",27 "customer.tier.promotion.code",28 "customer.tier.promotion.status",29 "promotions.*",30 "promotions.code",31 ],32 filters: {33 id: input.cart_id,34 },35 options: {36 throwIfKeyNotFound: true,37 },38 })39 40 41 // Check if customer exists and has tier42 const validationResult = when({ carts }, (data) => !!data.carts[0].customer).then(() => {43 return validateTierPromotionStep({44 customer: {45 has_account: carts[0].customer!.has_account,46 tier: {47 promo_id: carts[0].customer!.tier!.promo_id || null,48 promotion: {49 id: carts[0].customer!.tier!.promotion!.id,50 code: carts[0].customer!.tier!.promotion!.code || null,51 // @ts-ignore52 status: carts[0].customer!.tier!.promotion!.status || null,53 },54 },55 },56 })57 })58 59 // Add promotion to cart if valid and not already applied60 when({ validationResult, carts }, (data) => {61 if (!data.validationResult?.promotion_code) {62 return false63 }64 65 const appliedPromotionCodes = data.carts[0].promotions?.map(66 (promo: any) => promo.code67 ) || []68 69 return (70 data.validationResult?.promotion_code !== null &&71 !appliedPromotionCodes.includes(data.validationResult?.promotion_code!)72 )73 }).then(() => {74 return updateCartPromotionsWorkflow.runAsStep({75 input: {76 cart_id: input.cart_id,77 promo_codes: [validationResult?.promotion_code!],78 action: PromotionActions.ADD,79 },80 })81 })82 83 return new WorkflowResponse(void 0)84 }85)
The workflow receives the cart's ID as input.
In the workflow, you:
- Retrieve the cart details using
useQueryGraphStep. - Validate that the customer exists and has a tier promotion using
validateTierPromotionStep. - Update the cart's promotions if the customer has a tier promotion that hasn't been applied yet, using
updateCartPromotionsWorkflow.
b. Cart Updated Subscriber#
Next, you'll create a subscriber that listens to the cart update event and executes the workflow.
To create the subscriber, create the file src/subscribers/cart-updated.ts with the following content:
1import {2 SubscriberArgs,3 SubscriberConfig,4} from "@medusajs/framework"5import { addTierPromotionToCartWorkflow } from "../workflows/add-tier-promotion-to-cart"6 7export default async function cartUpdatedHandler({8 event: { data },9 container,10}: SubscriberArgs<{ id: string }>) {11 await addTierPromotionToCartWorkflow(container).run({12 input: {13 cart_id: data.id,14 },15 })16}17 18export const config: SubscriberConfig = {19 event: "cart.updated",20}
The subscriber listens to the cart.updated event and executes the workflow.
Test Add Tier Promotion to Cart#
To test the automated tier promotion, make sure both the Medusa application and the Next.js Starter Storefront are running.
Then, in the Next.js Starter Storefront, log in as a customer in a tier, and add a product to the cart.
You can see that the discount of the customer's tier is applied to the cart.

Step 13: Validate Applied Promotion#
In this step, you'll validate that a cart's promotions match the customer's tier. You'll apply validation when new promotions are added to the cart, and when completing the cart.
Validate Promotion Addition#
When a customer adds a promotion to the cart, the storefront sends a request to the Add Promotions API Route, which executes the updateCartPromotionsWorkflow.
To ensure that customers don't add promotions that belong to different tiers, you can consume the validate hook of the updateCartPromotionsWorkflow. A hook is a specific point in a workflow where you can inject custom functionality.
To consume the hook, create the file src/workflows/hooks/update-cart-promotions-validate.ts with the following content:
1import { updateCartPromotionsWorkflow } from "@medusajs/medusa/core-flows"2import { MedusaError } from "@medusajs/framework/utils"3import { PromotionActions } from "@medusajs/framework/utils"4 5updateCartPromotionsWorkflow.hooks.validate(async ({ input, cart }, { container }) => {6 const query = container.resolve("query")7 8 // Only validate when adding promotions9 if (10 (input.action !== PromotionActions.ADD && input.action !== PromotionActions.REPLACE) || 11 !input.promo_codes || input.promo_codes.length === 012 ) {13 return14 }15 16 // Get customer details with tier17 const data = cart.customer_id ? await query.graph({18 entity: "customer",19 fields: ["id", "tier.*"],20 filters: {21 id: cart.customer_id,22 },23 }) : null24 25 // Get customer's tier26 const customerTier = data?.data?.[0]?.tier27 28 // Get promotions by codes to check if they're tier promotions29 const { data: promotions } = await query.graph({30 entity: "promotion",31 fields: ["id", "code"],32 filters: {33 code: input.promo_codes,34 },35 })36 37 // Get all tiers with their promotion IDs38 const { data: allTiers } = await query.graph({39 entity: "tier",40 fields: ["id", "promo_id"],41 filters: {42 promo_id: promotions.map((p) => p.id),43 },44 })45 46 // Validate each promotion being added47 for (const promotion of promotions || []) {48 const tierId = allTiers.find((t) => t.promo_id === promotion?.id)?.id49 50 // If this promotion belongs to a tier51 if (tierId && customerTier?.id !== tierId) {52 throw new MedusaError(53 MedusaError.Types.INVALID_DATA,54 `Promotion ${promotion.code || promotion.id} can only be applied by customers in the corresponding tier.`55 )56 }57 }58})
You consume a hook by accessing it through the workflow's hooks property. The hook accepts a step function as a parameter.
In the hook, you:
- Return early if the customer isn't adding a promotion.
- Retrieve the customer if it's set in the cart.
- Retrieve promotions being added to the cart.
- Retrieve all tiers associated with the promotions.
- Loop over the promotions and check if the customer belongs to the tier associated with the promotion.
- If not, you throw an error, which will stop the customer from adding the promotion to the cart.
Test Validate Promotion Addition#
To test the promotion addition validation, make sure both the Medusa application and the Next.js Starter Storefront are running.
Then, in the Next.js Starter Storefront, log in as a customer in a tier, and add a product to the cart.
Try to add a promotion that belongs to a different tier. You should see an error message.

Validate Cart Completion#
Next, you'll add similar validation for the cart's promotions when completing the cart. You'll perform the validation by consuming the validate hook of the completeCartWorkflow.
Create the file src/workflows/hooks/complete-cart-validate.ts with the following content:
1import { completeCartWorkflow } from "@medusajs/medusa/core-flows"2import { MedusaError } from "@medusajs/framework/utils"3 4completeCartWorkflow.hooks.validate(async ({ cart }, { container }) => {5 const query = container.resolve("query")6 7 // Get cart with promotions8 const { data: [detailedCart] } = await query.graph({9 entity: "cart",10 fields: ["id", "promotions.*", "customer.id", "customer.tier.*"],11 filters: {12 id: cart.id,13 },14 }, {15 throwIfKeyNotFound: true,16 })17 18 if (!detailedCart?.promotions || detailedCart.promotions.length === 0) {19 return20 }21 22 // Get customer's tier23 const customerTier = detailedCart.customer?.tier24 25 // Get all tier promotions to check26 const { data: allTiers } = await query.graph({27 entity: "tier",28 fields: ["id", "promo_id"],29 filters: {30 promo_id: detailedCart.promotions.map((p) => p?.id).filter(Boolean) as string[],31 },32 })33 34 // Validate that if a tier promotion is applied, the customer belongs to that tier35 for (const promotion of detailedCart.promotions) {36 const tierId = allTiers.find((t) => t.promo_id === promotion?.id)?.id37 if (tierId && customerTier?.id !== tierId) {38 throw new MedusaError(39 MedusaError.Types.INVALID_DATA,40 `Promotion ${promotion?.code || promotion?.id} can only be applied by customers in the corresponding tier.`41 )42 }43 }44})
Similar to the previous hook, you:
- Retrieve the cart with its promotions and customer tier.
- If the cart doesn't have promotions, return early.
- Retrieve the tiers associated with the applied promotions.
- Loop over the promotions and check if any promotion doesn't belong to the customer's tier.
- If so, you throw an error, which will stop the customer from completing the cart.
This validation will run every time the cart is completed and before the order is placed.
Step 14: Show Customer Tier in Storefront#
In this step, you'll add an API route that retrieves a customer's current tier and their next tier. Then, you'll customize the Next.js Starter Storefront to show a tier progress indicator on the account and order confirmation pages.
a. Calculate Next Tier Method#
To calculate the customer's next tier, you'll add a method to the Tier Module's service. You'll then use that method in the API route to retrieve the customer's next tier.
In src/modules/tier/service.ts, add the following method to the TierModuleService class:
1class TierModuleService extends MedusaService({2 Tier,3 TierRule,4}) {5 // ...6 async calculateNextTierUpgrade(7 currencyCode: string,8 currentPurchaseValue: number9 ) {10 const rules = await this.listTierRules(11 {12 currency_code: currencyCode,13 },14 {15 relations: ["tier"],16 }17 )18 19 // Sort rules by min_purchase_value in ascending orderding order20 const sortedRules = rules.sort(21 (a, b) => a.min_purchase_value - b.min_purchase_value22 )23 24 // Find the next tier the customer hasn't reached25 const nextRule = sortedRules.find(26 (rule) => rule.min_purchase_value > currentPurchaseValue27 )28 29 if (!nextRule || !nextRule.tier) {30 return null31 }32 33 const requiredAmount = nextRule.min_purchase_value - currentPurchaseValue34 35 return {36 tier: nextRule.tier,37 required_amount: requiredAmount,38 current_purchase_value: currentPurchaseValue,39 next_tier_min_purchase: nextRule.min_purchase_value,40 }41 }42}
The calculateNextTierUpgrade method receives the currency code and the current purchase value of a customer.
In the method, you:
- Retrieve the tier rules for the given currency code.
- Sort the rules by the minimum purchase value in ascending order.
- Find the next tier the customer hasn't reached.
- Return the next tier, the required amount to reach the next tier, the current purchase value, and the minimum purchase value of the next tier.
b. Next Tier API Route#
Next, you'll create an API route that retrieves a customer's current tier and their next tier.
To create the API route, create the file src/api/store/customers/me/next-tier/route.ts with the following content:
1import {2 AuthenticatedMedusaRequest,3 MedusaResponse,4} from "@medusajs/framework/http"5import { TIER_MODULE } from "../../../../../modules/tier"6import { MedusaError } from "@medusajs/framework/utils"7import { z } from "zod"8import { OrderStatus } from "@medusajs/framework/utils"9 10export const NextTierSchema = z.object({11 region_id: z.string(),12})13 14type NextTierInput = z.infer<typeof NextTierSchema>15 16export async function GET(17 req: AuthenticatedMedusaRequest<{}, NextTierInput>,18 res: MedusaResponse19): Promise<void> {20 // Validate customer is authenticated21 const customerId = req.auth_context?.actor_id22 23 if (!customerId) {24 throw new MedusaError(25 MedusaError.Types.UNAUTHORIZED,26 "Customer must be authenticated"27 )28 }29 30 const query = req.scope.resolve("query")31 const tierModuleService = req.scope.resolve(TIER_MODULE)32 33 // Get customer details to validate they're registered34 const { data: [customer] } = await query.graph({35 entity: "customer",36 fields: ["id", "has_account", "tier.*"],37 filters: {38 id: customerId,39 },40 }, {41 throwIfKeyNotFound: true,42 })43 44 if (!customer.has_account) {45 throw new MedusaError(46 MedusaError.Types.INVALID_DATA,47 "Customer must be registered to view tier information"48 )49 }50 51 // Get currency code from cart or region context52 // Try to get from cart first, then region53 const regionId = req.validatedQuery.region_id54 55 // Calculate total purchase value56 const { data: orders } = await query.graph({57 entity: "order",58 fields: ["id", "total", "currency_code"],59 filters: {60 customer_id: customerId,61 region_id: regionId,62 status: {63 $nin: [64 OrderStatus.CANCELED,65 OrderStatus.DRAFT,66 ],67 },68 },69 })70 71 // Get currency code from region if no orders72 let currencyCode: string | null = null73 if (orders.length > 0) {74 currencyCode = orders[0].currency_code75 } else {76 // Get currency from region77 const { data: regions } = await query.graph({78 entity: "region",79 fields: ["id", "currency_code"],80 filters: {81 id: regionId,82 },83 })84 85 if (regions && regions.length > 0) {86 currencyCode = regions[0].currency_code87 }88 }89 90 const totalPurchaseValue = orders.length > 091 ? orders.reduce((sum: number, order: any) => sum + (order.total || 0), 0)92 : 093 94 // Current tier is always the customer's assigned tier (null if not assigned)95 const currentTier = customer.tier || null96 97 // Determine next tier upgrade98 const nextTierUpgrade = await tierModuleService.calculateNextTierUpgrade(99 currencyCode as string,100 totalPurchaseValue101 )102 103 res.json({104 current_tier: currentTier,105 current_purchase_value: totalPurchaseValue,106 currency_code: currencyCode,107 next_tier_upgrade: nextTierUpgrade,108 })109}
You first define a Zod schema to validate incoming requests. Requests must have a region_id query parameter. This determines the currency code to use for the tier calculation.
Then, you export a GET function, which exposes a GET API route at /store/customers/me/next-tier.
In the route handler, you:
- Validate that the customer is authenticated.
- Resolve Query and the Tier Module service from the Medusa container.
- Retrieve the customer details to validate that they're registered.
- Retrieve the currency code from the cart or region context.
- Calculate the total purchase value of the customer.
- Determine the customer's current tier.
- Determine the customer's next tier upgrade.
- Return the customer's current tier, current purchase value, currency code, and next tier upgrade in the response.
c. Apply Query Validation Middleware#
Next, you need to apply a middleware that validates the query parameters passed to the request, and sets the default Query configurations.
In src/api/middlewares.ts, add the import at the top of the file:
Then, add the following object to the routes array passed to defineMiddlewares:
You apply the validateAndTransformQuery middleware to the GET route of the /store/customers/me/next-tier path, passing it the NextTierSchema schema to validate the request parameters.
d. Show Customer Tier in Storefront#
Next, you'll customize the Next.js Starter Storefront to show a tier progress indicator in the account and order confirmation pages.
Define Tier Types
First, you'll define types for the tier information received from the Medusa server.
Create the file src/types/tier.ts in the storefront with the following content:
1export type Tier = {2 id: string3 name: string4 promotion_id: string5}6 7export type CustomerNextTier = {8 current_tier: Tier | null9 current_purchase_value: number10 currency_code: string11 next_tier_upgrade: {12 tier: Tier | null13 required_amount: number14 current_purchase_value: number15 next_tier_min_purchase: number16 } | null17}
The Tier type represents a tier, and the CustomerNextTier type represents the response received from the /store/customers/me/next-tier API route.
Retrieve Customer Tier and Next Tier
Next, you'll add a server function that retrieves the customer's current tier and their next tier.
In src/lib/data/customer.ts, add the following imports at the top of the file:
Then, add the following function to the file:
1export const retrieveCustomerNextTier =2 async (countryCode: string): Promise<CustomerNextTier | null> => {3 const authHeaders = await getAuthHeaders()4 const region = await getRegion(countryCode)5 6 if (!region) {return null}7 8 if (!authHeaders) {return null}9 10 const headers = {11 ...authHeaders,12 }13 14 const next = {15 ...(await getCacheOptions("customers")),16 }17 18 return await sdk.client19 .fetch<CustomerNextTier>(`/store/customers/me/next-tier`, {20 method: "GET",21 headers,22 next,23 query: {24 region_id: region.id,25 },26 })27 .then((data) => data)28 .catch(() => null)29}
You create a function that retrieves the customer's current tier and their next tier from the /store/customers/me/next-tier API route.
Add Customer Tier Component
Next, you'll create a component that displays a progress indicator for the customer's tier. You'll use this component on the account and order confirmation pages.
Create the file src/modules/common/customer-tier/index.tsx with the following content:
1import { convertToLocale } from "@lib/util/money"2import { clx } from "@medusajs/ui"3import { CustomerNextTier } from "types/tier"4 5type CustomerTierProps = {6 tierData: CustomerNextTier | null7}8 9const CustomerTier = ({ tierData }: CustomerTierProps) => {10 if (!tierData) {11 return null12 }13 14 const { current_tier, current_purchase_value, currency_code, next_tier_upgrade } = tierData15 16 // Calculate progress if there's a next tier17 let progressPercentage = 10018 let amountNeeded = 019 let minPurchaseValue = 020 let hasNextTier = false21 let nextTierName = ""22 23 if (next_tier_upgrade && next_tier_upgrade.tier) {24 hasNextTier = true25 nextTierName = next_tier_upgrade.tier.name26 amountNeeded = next_tier_upgrade.required_amount27 minPurchaseValue = next_tier_upgrade.next_tier_min_purchase28 29 // Calculate progress percentage30 // Use the current purchase value and next tier min purchase from the API31 const currentPurchase = next_tier_upgrade.current_purchase_value32 const nextMin = next_tier_upgrade.next_tier_min_purchase33 34 // Calculate progress: current purchase value / next tier min purchase35 if (nextMin > 0) {36 progressPercentage = Math.min(100, Math.max(0, (currentPurchase / nextMin) * 100))37 } else {38 progressPercentage = 10039 }40 }41 42 // If no current tier and no next tier, don't show anything43 if (!current_tier && !hasNextTier) {44 return null45 }46 47 return (48 <div className="flex flex-col gap-y-4">49 <h3 className="text-large-semi">Membership Tier</h3>50 <div className="flex flex-col gap-y-3">51 {current_tier ? (52 <div className="flex items-center gap-x-2">53 <span className="text-large-semi" data-testid="current-tier-name">54 {current_tier.name}55 </span>56 </div>57 ) : (58 <div className="flex items-center gap-x-2">59 <span className="text-large-semi text-ui-fg-subtle" data-testid="current-tier-name">60 No tier61 </span>62 </div>63 )}64 65 {hasNextTier && (66 <div className="flex flex-col gap-y-2">67 <div className="flex justify-between text-small-regular text-ui-fg-subtle">68 <span>Progress to {nextTierName}</span>69 {amountNeeded > 0 ? (70 <span>71 {convertToLocale({72 amount: amountNeeded,73 currency_code: currency_code,74 })}{" "}75 to go76 </span>77 ) : (78 <span className="text-ui-fg-interactive">Threshold reached!</span>79 )}80 </div>81 <div className="flex">82 <div83 className={clx(84 `h-2 rounded-s-full transition-all duration-500 ease-in-out`,85 progressPercentage >= 10086 ? "bg-gradient-to-r from-green-400 to-green-500"87 : "bg-gradient-to-r from-ui-fg-interactive to-ui-fg-interactive-hover",88 progressPercentage === 100 && "rounded-e-full"89 )}90 style={{ width: `${progressPercentage}%` }}91 data-testid="tier-progress-bar"92 />93 <div className={clx(94 "bg-gray-200 h-2 rounded-e-full flex-grow",95 progressPercentage === 0 && "rounded-s-full"96 )} />97 </div>98 <div className="flex justify-between text-xs text-ui-fg-subtle">99 <span>100 {convertToLocale({101 amount: next_tier_upgrade?.current_purchase_value || current_purchase_value,102 currency_code: currency_code,103 })}104 </span>105 {minPurchaseValue > 0 && (106 <span>107 {convertToLocale({108 amount: minPurchaseValue,109 currency_code: currency_code,110 })}111 </span>112 )}113 </div>114 </div>115 )}116 117 {!hasNextTier && current_tier && (118 <div className="text-small-regular text-ui-fg-subtle">119 You've reached the highest tier!120 </div>121 )}122 </div>123 </div>124 )125}126 127export default CustomerTier
The component receives the tier data retrieved from the Medusa server as a prop. It then calculates and displays a progress bar indicating how much the customer needs to spend to unlock the next tier.
Show Customer Tier on Account Page
Next, you'll show the customer's tier on the account page.
In src/modules/account/components/overview/index.tsx, add the following imports at the top of the file:
Then, update the Overview component's props to include the tierData prop:
Finally, update the return statement to render the CustomerTier component before the div wrapping the recent orders:
To pass the tier data to the Overview component, replace the content of src/app/[countryCode]/(main)/account/@dashboard/page.tsx with the following:
1import { Metadata } from "next"2 3import Overview from "@modules/account/components/overview"4import { notFound } from "next/navigation"5import { retrieveCustomer, retrieveCustomerNextTier } from "@lib/data/customer"6import { listOrders } from "@lib/data/orders"7 8export const metadata: Metadata = {9 title: "Account",10 description: "Overview of your account activity.",11}12 13type Props = {14 params: Promise<{ countryCode: string }>15}16 17export default async function OverviewTemplate(props: Props) {18 const params = await props.params19 const { countryCode } = params20 const customer = await retrieveCustomer().catch(() => null)21 const orders = (await listOrders().catch(() => null)) || null22 const tierData = await retrieveCustomerNextTier(countryCode).catch(() => null)23 24 if (!customer) {25 notFound()26 }27 28 return <Overview customer={customer} orders={orders} tierData={tierData} />29}
You make the following key changes:
- Add the import for the
retrieveCustomerNextTierfunction. - Add the
countryCodeparameter type to theOverviewTemplatecomponent props. - Retrieve the customer's tier and next tier information using the
retrieveCustomerNextTierfunction. - Pass the tier data to the
Overviewcomponent.
Test Customer Tier on Account Page
To test the customer tier component on the account page, make sure both the Medusa server and the Next.js Starter Storefront are running.
Then, on the storefront, click "Account" in the navigation bar. The main account page will display the customer's current tier with a progress bar showing their progress toward the next tier.

Show Customer Tier on Order Confirmation Page
Next, you'll show the customer tier component on the order confirmation page.
In src/modules/order/templates/order-completed-template.tsx, add the following imports at the top of the file:
Then, update the OrderCompletedTemplate component's props to include the tierData prop:
Finally, update the return statement to render the CustomerTier component before the "Summary" heading:
To pass the tier data to the OrderCompletedTemplate component, open src/app/[countryCode]/(main)/order/[id]/confirmed/page.tsx and replace the content with the following:
1import { retrieveOrder } from "@lib/data/orders"2import OrderCompletedTemplate from "@modules/order/templates/order-completed-template"3import { Metadata } from "next"4import { notFound } from "next/navigation"5import { retrieveCustomerNextTier } from "@lib/data/customer"6 7type Props = {8 params: Promise<{ id: string; countryCode: string }>9}10export const metadata: Metadata = {11 title: "Order Confirmed",12 description: "You purchase was successful",13}14 15export default async function OrderConfirmedPage(props: Props) {16 const params = await props.params17 const order = await retrieveOrder(params.id).catch(() => null)18 const tierData = await retrieveCustomerNextTier(params.countryCode).catch(() => null)19 20 if (!order) {21 return notFound()22 }23 24 return <OrderCompletedTemplate order={order} tierData={tierData} />25}
You make the following key changes:
- Add the import for the
retrieveCustomerNextTierfunction. - Add the
countryCodeparameter to theOrderConfirmedPagecomponent. - Retrieve the customer's tier and their next tier using the
retrieveCustomerNextTierfunction. - Pass the tier data to the
OrderCompletedTemplatecomponent.
Test Customer Tier on Order Confirmation Page
To test the customer tier component on the order confirmation page, make sure both the Medusa server and the Next.js Starter Storefront are running.
Then, on the storefront, place an order. The order confirmation page will display the customer's tier with a progress bar showing their progress toward the next tier.

Next Steps#
You've now implemented customer tiers in Medusa. You can expand on this feature to add more features like:
- Automated emails to customers when they reach a new tier. Cloud users can benefit from zero-config email setup with Medusa Emails.
- More complex tier rules, such as rules based on product categories or collections.
- Other tier privileges, such as early access to new products or free shipping.
If you're new to Medusa, check out the main documentation for a more in-depth understanding of the concepts you've used in this guide and more.
To learn more about the commerce features Medusa provides, check out 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:
- Visit the Medusa GitHub repository to report issues or ask questions.
- Join the Medusa Discord community for real-time support from community members.