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.

Diagram illustrating how the customer tiers system works, starting from the customer adding a product to the cart, Medusa applying the tier promotion automatically, customer placing the order, and Medusa updating the customer's tier.

Customer Tiers Repository
Find the full code for this guide in this repository.
OpenAPI Specs for Postman
Import this OpenAPI Specs file into tools like Postman.

Step 1: Install a Medusa Application#

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

Terminal
npx create-medusa-app@latest

You'll first be asked for the project's name. Then, when asked whether you want to install the Next.js Starter Storefront, choose Yes.

Afterward, the installation process will start, which will install the Medusa application in a directory with your project's name, and the Next.js Starter Storefront in a separate directory with the {project-name}-storefront name.

Why is the storefront installed separately: The Medusa application is composed of a headless Node.js server and an admin dashboard. The storefront is installed or custom-built separately and connects to the Medusa application through its REST endpoints, called API routes. Learn more in Medusa's Architecture documentation.

Once the installation finishes successfully, the Medusa Admin dashboard will open with a form to create a new user. Enter the user's credentials and submit the form. Afterward, you can log in with the new user and explore the dashboard.

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

Step 2: Create 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.

Note: Refer to the Modules documentation to learn more.

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.

Note: Refer to the Data Models documentation to learn more.

For the Tier Module, you need to define two data models:

  1. Tier: Represents a customer tier (for example, Bronze, Silver, Gold).
  2. 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:

src/modules/tier/models/tier.ts
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 with TierRule data model. Ignore the type error as you'll define the TierRule data model next.
Note: Learn more about defining data model properties in the Property Types documentation.

Next, create the file src/modules/tier/models/tier-rule.ts with the following content:

src/modules/tier/models/tier-rule.ts
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 the Tier data model.

You also add a unique index on tier_id and currency_code to ensure that each tier has only one rule per currency.

Tip: Alternatively, you can store the minimum purchase value for a specific currency, then integrate with real-time exchange rate services to convert values between currencies. However, for simplicity, this tutorial uses fixed amounts for each 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.

Note: Refer to the Module Service documentation to learn more.

To create the Tier Module's service, create the file src/modules/tier/service.ts with the following content:

src/modules/tier/service.ts
1import { MedusaService } from "@medusajs/framework/utils"2import { Tier } from "./models/tier"3import { TierRule } from "./models/tier-rule"4
5class TierModuleService extends MedusaService({6  Tier,7  TierRule,8}) {9}10
11export default TierModuleService

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.

Note: Find all methods generated by the MedusaService in the Service Factory reference.

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:

src/modules/tier/index.ts
1import TierModuleService from "./service"2import { Module } from "@medusajs/framework/utils"3
4export const TIER_MODULE = "tier"5
6export default Module(TIER_MODULE, {7  service: TierModuleService,8})

You use the Module function from the Modules SDK to create the module's definition. It accepts two parameters:

  1. The module's name, which is tier.
  2. An object with a required property service indicating 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:

medusa-config.ts
1module.exports = defineConfig({2  // ...3  modules: [4    {5      resolve: "./src/modules/tier",6    },7  ],8})

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.

Note: Refer to the Migrations documentation to learn more.

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:

Terminal
npx medusa db:generate tier

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:

Terminal
npx medusa db:migrate

The tables for the Tier and TierRule data models are now created in the database.


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.

Note: Refer to the Module Isolation documentation to learn more.

In this step, you'll define:

  1. A link between the Tier Module's Tier data model and the Customer Module's Customer data model.
  2. A read-only link between the Tier Module's Tier data model and the Promotion Module's Promotion data model.

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:

src/links/tier-customer.ts
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:

  1. An object indicating the first data model in the link. You pass the link configurations for the Tier data model from the Tier Module. You also specify the id property as filterable, allowing you to filter customers by their tier later using the Index Module.
  2. An object indicating the second data model in the link. You pass the linkable configurations of the Customer Module's Customer data model. You set isList to true because a tier can have multiple customers.

This link allows you to retrieve and manage customers associated with a tier, and vice versa.

Next, create the file src/links/tier-promotion.ts with the following content:

src/links/tier-promotion.ts
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.

Note: Learn more about workflows in the Workflows documentation.

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:

src/workflows/steps/create-tier.ts
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:

  1. The step's unique name, which is create-tier.
  2. 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.
  3. 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:

  1. The step's output, which is the tier created.
  2. 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:

src/workflows/steps/create-tier-rules.ts
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:

src/workflows/create-tier.ts
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 throwIfKeyNotFound option, the useQueryGraphStep throws 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.
  • 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.

Tip: In workflows, you need 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.

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

Create the file src/api/admin/tiers/route.ts with the following content:

src/api/admin/tiers/route.ts
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:

src/api/middlewares.ts
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.

Tip: Refer to the Middlewares documentation to learn more.

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:

src/api/admin/tiers/route.ts
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:

src/api/middlewares.ts
1import {2  validateAndTransformQuery,3} from "@medusajs/framework/http"4import { createFindParams } from "@medusajs/medusa/api/utils/validators"

Then, add the following object to the routes array passed to defineMiddlewares:

src/api/middlewares.ts
1export default defineMiddlewares({2  routes: [3    // ...4    {5      matcher: "/admin/tiers",6      methods: ["GET"],7      middlewares: [validateAndTransformQuery(createFindParams(), {8        isList: true,9        defaults: ["id", "name", "promotion.id", "promotion.code"],10      })],11    },12  ],13})

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 to true to 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.
Note: Refer to the Request Query Configuration documentation to learn more about this middleware and the query configurations.

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.

Tip: Refer to the Admin Development documentation to learn more.

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:

src/admin/lib/sdk.ts
1import Medusa from "@medusajs/js-sdk"2
3export const sdk = new Medusa({4  baseUrl: process.env.MEDUSA_BACKEND_URL || "http://localhost:9000",5  debug: process.env.NODE_ENV === "development",6  auth: {7    type: "session",8  },9})

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.

Note: Learn more about UI routes in the UI Routes documentation.

To create the UI route, create the file src/admin/routes/tiers/page.tsx with the following content:

src/admin/routes/tiers/page.tsx
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:

src/admin/components/create-tier-modal.tsx
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:

src/admin/components/create-tier-modal.tsx
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:

src/admin/components/create-tier-modal.tsx
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 the createTierMutation, 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:

src/admin/components/create-tier-modal.tsx
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:

  1. Name: The name of the tier.
  2. Promotion: A select input to choose a promotion that's associated with the tier.
  3. 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:

src/admin/routes/tiers/page.tsx
import { CreateTierModal } from "../../components/create-tier-modal"

Next, replace the TODO in the TiersPage component's return statement with the following:

src/admin/routes/tiers/page.tsx
<CreateTierModal open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen} />

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.

Customer Tiers page showing 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.

Create tier modal showing form to create a 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:

src/api/admin/tiers/[id]/route.ts
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:

src/api/middlewares.ts
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:

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:

src/workflows/steps/update-tier.ts
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:

src/workflows/steps/delete-tier-rules.ts
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:

src/workflows/update-tier.ts
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:

  1. Retrieve the tier to update using the useQueryGraphStep.
  2. Use when-then to check if the promotion ID is provided. If so, use useQueryGraphStep to validate that it exists.
  3. Update the tier using the updateTierStep.
  4. If new tier rules are provided, you:
    • delete the existing tier rules using the deleteTierRulesStep.
    • Create the new tier rules using the createTierRulesStep.
  5. Retrieve the updated tier with rules using the useQueryGraphStep.

Finally, you return a WorkflowResponse with the updated tier.

Note: In workflows, you need 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:

src/api/admin/tiers/[id]/route.ts
1import { z } from "zod"2import { updateTierWorkflow } from "../../../../workflows/update-tier"

Then, add the following at the end of the file:

src/api/admin/tiers/[id]/route.ts
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:

src/api/middlewares.ts
import { UpdateTierSchema } from "./admin/tiers/[id]/route"

Then, add a new route object passed to the array in defineMiddlewares:

src/api/middlewares.ts
1export default defineMiddlewares({2  routes: [3    // ...4    {5      matcher: "/admin/tiers/:id",6      methods: ["POST"],7      middlewares: [validateAndTransformBody(UpdateTierSchema)],8    },9  ],10})

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:

src/api/admin/tiers/[id]/customers/route.ts
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:

src/api/middlewares.ts
1export default defineMiddlewares({2  routes: [3    // ...4    {5      matcher: "/admin/tiers/:id/customers",6      methods: ["GET"],7      middlewares: [8        validateAndTransformQuery(createFindParams(), {9          isList: true,10          defaults: ["id", "email", "first_name", "last_name"],11        }),12      ],13    },14  ],15})

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 to true to 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:

medusa-config.ts
1export default config({2  modules: [3    // ...4    {5      resolve: "@medusajs/index",6    },7  ],8})

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.

Note: Refer to the Index Module documentation to learn more.

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:

src/admin/components/edit-tier-drawer.tsx
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:

src/admin/components/edit-tier-drawer.tsx
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:

src/admin/components/edit-tier-drawer.tsx
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:

src/admin/components/edit-tier-drawer.tsx
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 the updateTierMutation mutation 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:

src/admin/components/edit-tier-drawer.tsx
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:

  1. Name: The name of the tier.
  2. Promotion: A select input to choose a promotion that's associated with the tier.
  3. 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:

src/admin/components/tier-details-section.tsx
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:

src/admin/components/tier-rules-table.tsx
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:

src/admin/components/tier-customers-table.tsx
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:

src/admin/routes/tiers/[id]/page.tsx
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:

src/admin/routes/tiers/page.tsx
1onRowClick: (_event, row) => {2  navigate(`/tiers/${row.id}`)3}

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:

src/admin/components/create-tier-modal.tsx
navigate(`/tiers/${data.tier.id}`)

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.

Tier details page showing tier details, rules, and 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.

Edit tier drawer showing form to edit tier details


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:

src/modules/tier/service.ts
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:

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:

src/workflows/steps/validate-customer.ts
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:

src/workflows/steps/determine-tier.ts
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:

src/workflows/update-customer-tier-on-order.ts
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:

src/subscribers/order-placed.ts
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.placed in this case.

The subscriber function receives an object with the following properties:

  • event: An object holding the event's details. It has a data property, 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.

Reminder: 

The Next.js Starter Storefront was installed in a separate directory from Medusa. The directory's name is {your-project}-storefront.

So, if your Medusa application's directory is medusa-customer-tiers, you can find the storefront by going back to the parent directory and changing to the medusa-customer-tiers-storefront directory:

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

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:

  1. Open the Next.js Starter Storefront in your browser at http://localhost:8000.
  2. Click on "Account" in the navigation bar and create a new account.
  3. 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.
  4. After you place the order, open the Medusa Admin dashboard at http://localhost:9000/app and log in.
  5. Go to the Customer Tiers page and click on the tier that the customer should have been assigned to.
  6. You'll see the customer in the tier's customers list section.

Customer in 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:

src/workflows/steps/validate-tier-promotion.ts
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:

src/workflows/add-tier-promotion-to-cart.ts
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:

src/subscribers/cart-updated.ts
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.

Note: If you don't see the promotion applied, try to refresh the page.

Cart showing the promotion of the tier applied


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:

src/workflows/hooks/update-cart-promotions-validate.ts
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:

  1. Return early if the customer isn't adding a promotion.
  2. Retrieve the customer if it's set in the cart.
  3. Retrieve promotions being added to the cart.
  4. Retrieve all tiers associated with the promotions.
  5. 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.

Error message when trying to add a promotion of a different tier to the cart

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:

src/workflows/hooks/complete-cart-validate.ts
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:

  1. Retrieve the cart with its promotions and customer tier.
    • If the cart doesn't have promotions, return early.
  2. Retrieve the tiers associated with the applied promotions.
  3. 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:

src/modules/tier/service.ts
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:

src/api/store/customers/me/next-tier/route.ts
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:

src/api/middlewares.ts
import { NextTierSchema } from "./store/customers/me/next-tier/route"

Then, add the following object to the routes array passed to defineMiddlewares:

src/api/middlewares.ts
1export default defineMiddlewares({2  routes: [3    // ...4    {5      matcher: "/store/customers/me/next-tier",6      methods: ["GET"],7      middlewares: [validateAndTransformQuery(NextTierSchema, {})],8    },9  ],10})

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:

Storefront
src/types/tier.ts
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:

Storefront
src/lib/data/customer.ts
1import { CustomerNextTier } from "types/tier"2import { getRegion } from "./regions"

Then, add the following function to the file:

Storefront
src/lib/data/customer.ts
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:

Storefront
src/modules/common/customer-tier/index.tsx
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&apos;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:

Storefront
src/modules/account/components/overview/index.tsx
1import CustomerTier from "@modules/common/customer-tier"2import { CustomerNextTier } from "types/tier"

Then, update the Overview component's props to include the tierData prop:

Storefront
src/modules/account/components/overview/index.tsx
1type OverviewProps = {2  // ...3  tierData: CustomerNextTier | null4}5
6const Overview = ({ customer, orders, tierData }: OverviewProps) => {7  // ...8}

Finally, update the return statement to render the CustomerTier component before the div wrapping the recent orders:

Storefront
src/modules/account/components/overview/index.tsx
1return (2  <div>3    {/* ... */}4    {tierData && (5      <div className="mb-6">6        <CustomerTier tierData={tierData} />7      </div>8    )}9    {/* ... */}10  </div>11)

To pass the tier data to the Overview component, replace the content of src/app/[countryCode]/(main)/account/@dashboard/page.tsx with the following:

Storefront
src/app/[countryCode]/(main)/account/@dashboard/page.tsx
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 retrieveCustomerNextTier function.
  • Add the countryCode parameter type to the OverviewTemplate component props.
  • Retrieve the customer's tier and next tier information using the retrieveCustomerNextTier function.
  • Pass the tier data to the Overview component.

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.

Customer tier component on account page

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:

Storefront
src/modules/order/templates/order-completed-template.tsx
1import CustomerTier from "@modules/common/customer-tier"2import { CustomerNextTier } from "types/tier"

Then, update the OrderCompletedTemplate component's props to include the tierData prop:

Storefront
src/modules/order/templates/order-completed-template.tsx
1type OrderCompletedTemplateProps = {2  // ...3  tierData: CustomerNextTier | null4}5
6export default async function OrderCompletedTemplate({7  // ...8  tierData,9}: OrderCompletedTemplateProps) {10  // ...11}

Finally, update the return statement to render the CustomerTier component before the "Summary" heading:

Storefront
src/modules/order/templates/order-completed-template.tsx
1return (2  <div>3    {/* ... */}4    {tierData && (5      <div className="mt-6">6        <CustomerTier tierData={tierData} />7      </div>8    )}9    {/* ... */}10  </div>11)

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:

Storefront
src/app/[countryCode]/(main)/order/[id]/confirmed/page.tsx
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 retrieveCustomerNextTier function.
  • Add the countryCode parameter to the OrderConfirmedPage component.
  • Retrieve the customer's tier and their next tier using the retrieveCustomerNextTier function.
  • Pass the tier data to the OrderCompletedTemplate component.

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.

Customer tier component on order confirmation page


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:

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