Digital Products Recipe Example

In this guide, you'll learn how to support digital products in Medusa.

When you install a Medusa application, you get a fully-fledged commerce platform with support for customizations. Medusa provides all features related to products and managing them, and its framework allows you to extend those features and implement your custom use case.

You can extend Medusa's product features to support selling, storing, and fulfilling digital products. In this guide, you'll customize Medusa to add the following features:

  1. Support digital products with multiple media items.
  2. Manage digital products from the admin dashboard.
  3. Handle and fulfill digital product orders.
  4. Allow customers to download their digital product purchases from the storefront.
  5. All other commerce features that Medusa provides.
NoteThis guide provides an example of an approach to implement digital products. You're free to choose a different approach using Medusa's framework.
Digital Products Example Repository
Find the full code for this recipe example in this repository.
OpenApi Specs for Postman
Imported 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. You can also optionally choose to install the Next.js starter storefront.

Afterwards, the installation process will start, which will install the Medusa application in a directory with your project's name. If you chose to install the Next.js starter, it'll be installed 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 about Medusa's architecture in this documentation.

Once the installation finishes successfully, the Medusa Admin dashboard will open with a form to create a new user. Enter the user's credential and submit the form. Afterwards, you can login with the new user and explore the dashboard.

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

Step 2: Create the Digital Product Module#

Medusa creates commerce features in modules. For example, product features and data models are created in the Product Module.

You also create custom commerce data models and features in custom modules. They're integrated into the Medusa application similar to Medusa's modules without side effects.

So, you'll create a digital product module that holds the data models related to a digital product and allows you to manage them.

Create the directory src/modules/digital-product.

Create Data Models#

Create the file src/modules/digital-product/models/digital-product.ts with the following content:

src/modules/digital-product/models/digital-product.ts
1import { model } from "@medusajs/framework/utils"2import DigitalProductMedia from "./digital-product-media"3import DigitalProductOrder from "./digital-product-order"4
5const DigitalProduct = model.define("digital_product", {6  id: model.id().primaryKey(),7  name: model.text(),8  medias: model.hasMany(() => DigitalProductMedia, {9    mappedBy: "digitalProduct",10  }),11  orders: model.manyToMany(() => DigitalProductOrder, {12    mappedBy: "products",13  }),14})15.cascades({16  delete: ["medias"],17})18
19export default DigitalProduct

This creates a DigitalProduct data model. It has many medias and orders, which you’ll create next.

Create the file src/modules/digital-product/models/digital-product-media.ts with the following content:

src/modules/digital-product/models/digital-product-media.ts
1import { model } from "@medusajs/framework/utils"2import { MediaType } from "../types"3import DigitalProduct from "./digital-product"4
5const DigitalProductMedia = model.define("digital_product_media", {6  id: model.id().primaryKey(),7  type: model.enum(MediaType),8  fileId: model.text(),9  mimeType: model.text(),10  digitalProduct: model.belongsTo(() => DigitalProduct, {11    mappedBy: "medias",12  }),13})14
15export default DigitalProductMedia

This creates a DigitalProductMedia data model, which represents a media file that belongs to the digital product. The fileId property holds the ID of the uploaded file as returned by the File Module, which is explained in later sections.

Notice that the above data model uses an enum from a types file. So, create the file src/modules/digital-product/types/index.ts with the following content:

src/modules/digital-product/types/index.ts
1export enum MediaType {2  MAIN = "main",3  PREVIEW = "preview"4}

This enum indicates that a digital product media can either be used to preview the digital product, or is the main file available on purchase.

Next, create the file src/modules/digital-product/models/digital-product-order.ts with the following content:

src/modules/digital-product/models/digital-product-order.ts
1import { model } from "@medusajs/framework/utils"2import { OrderStatus } from "../types"3import DigitalProduct from "./digital-product"4
5const DigitalProductOrder = model.define("digital_product_order", {6  id: model.id().primaryKey(),7  status: model.enum(OrderStatus),8  products: model.manyToMany(() => DigitalProduct, {9    mappedBy: "orders",10    pivotTable: "digitalproduct_digitalproductorders",11  }),12})13
14export default DigitalProductOrder

This creates a DigitalProductOrder data model, which represents an order of digital products.

This data model also uses an enum from the types file. So, add the following to the src/modules/digital-product/types/index.ts file:

src/modules/digital-product/types/index.ts
1export enum OrderStatus {2  PENDING = "pending",3  SENT = "sent"4}

Create Main Module Service#

Next, create the main service of the module at src/modules/digital-product/service.ts with the following content:

src/modules/digital-product/service.ts
1import { MedusaService } from "@medusajs/framework/utils"2import DigitalProduct from "./models/digital-product"3import DigitalProductOrder from "./models/digital-product-order"4import DigitalProductMedia from "./models/digital-product-media"5
6class DigitalProductModuleService extends MedusaService({7  DigitalProduct,8  DigitalProductMedia,9  DigitalProductOrder,10}) {11
12}13
14export default DigitalProductModuleService

The service extends the service factory, which provides basic data-management features.

Create Module Definition#

After that, create the module definition at src/modules/digital-product/index.ts with the following content:

src/modules/digital-product/index.ts
1import DigitalProductModuleService from "./service"2import { Module } from "@medusajs/framework/utils"3
4export const DIGITAL_PRODUCT_MODULE = "digitalProductModuleService"5
6export default Module(DIGITAL_PRODUCT_MODULE, {7  service: DigitalProductModuleService,8})

Add Module to Medusa Configuration#

Finally, add the module to the list of modules in medusa-config.ts:

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

Further Reads#


Modules are isolated in Medusa, making them reusable, replaceable, and integrable in your application without side effects.

So, you can't have relations between data models in modules. Instead, you define a link between them.

Links are relations between data models of different modules that maintain the isolation between the modules.

In this step, you’ll define links between your module’s data models and data models from Medusa’s commerce modules:

  1. Link between the DigitalProduct model and the Product Module's ProductVariant model.
  2. Link between the DigitalProductOrder model and the Order Module's Order model.

Start by creating the file src/links/digital-product-variant.ts with the following content:

src/links/digital-product-variant.ts
1import DigitalProductModule from "../modules/digital-product"2import ProductModule from "@medusajs/medusa/product"3import { defineLink } from "@medusajs/framework/utils"4
5export default defineLink(6  DigitalProductModule.linkable.digitalProduct,7  ProductModule.linkable.productVariant8)

This defines a link between DigitalProduct and the Product Module’s ProductVariant. This allows product variants that customers purchase to be digital products.

Next, create the file src/links/digital-product-order.ts with the following content:

src/links/digital-product-order.ts
1import DigitalProductModule from "../modules/digital-product"2import OrderModule from "@medusajs/medusa/order"3import { defineLink } from "@medusajs/framework/utils"4
5export default defineLink(6  {7    linkable: DigitalProductModule.linkable.digitalProductOrder,8    deleteCascade: true,9  },10  OrderModule.linkable.order11)

This defines a link between DigitalProductOrder and the Order Module’s Order. This keeps track of orders that include purchases of digital products.

deleteCascades is enabled on the digitalProductOrder so that when a Medusa order is deleted, its linked digital product order is also deleted.

Further Read#


To create tables for the digital product data models in the database, start by generating the migrations for the Digital Product Module with the following command:

Terminal
npx medusa db:generate digitalProductModuleService

This generates a migration in the src/modules/digital-product/migrations directory.

Then, reflect the migrations and links in the database with the following command:

Terminal
npx medusa db:migrate

Step 5: List Digital Products Admin API Route#

To expose custom commerce features to frontend applications, such as the Medusa Admin dashboard or a storefront, you expose an endpoint by creating an API route.

In this step, you’ll create the admin API route to list digital products.

Create the file src/api/admin/digital-products/route.ts with the following content:

src/api/admin/digital-products/route.ts
1import { 2  AuthenticatedMedusaRequest, 3  MedusaResponse,4} from "@medusajs/framework/http"5import { ContainerRegistrationKeys } from "@medusajs/framework/utils"6
7export const GET = async (8  req: AuthenticatedMedusaRequest,9  res: MedusaResponse10) => {11  const { 12    fields, 13    limit = 20, 14    offset = 0,15  } = req.validatedQuery || {}16  const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)17
18  const { 19    data: digitalProducts,20    metadata: { count, take, skip },21  } = await query.graph({22    entity: "digital_product",23    fields: [24      "*",25      "medias.*",26      "product_variant.*",27      ...(fields || []),28    ],29    pagination: {30      skip: offset,31      take: limit,32    },33  })34
35  res.json({36    digital_products: digitalProducts,37    count,38    limit: take,39    offset: skip,40  })41}

This adds a GET API route at /admin/digital-products.

In the route handler, you use Query to retrieve the list of digital products and their relations. The route handler also supports pagination.

Test API Route#

To test out the API route, start the Medusa application:

Then, obtain a JWT token as an admin user with the following request:

Code
1curl -X POST 'http://localhost:9000/auth/user/emailpass' \2-H 'Content-Type: application/json' \3--data-raw '{4    "email": "admin@medusajs.com",5    "password": "supersecret"6}'

Finally, send the following request to retrieve the list of digital products:

Code
1curl -L 'http://localhost:9000/admin/digital-products' \2-H 'Authorization: Bearer {token}'

Make sure to replace {token} with the JWT token you retrieved.

Further Reads#


Step 6: Create Digital Product Workflow#

To implement and expose a feature that manipulates data, you create a workflow that uses services to implement the functionality, then create an API route that executes that workflow.

In this step, you’ll create a workflow that creates a digital product. You’ll use this workflow in an API route in the next section.

This workflow has the following steps:

  1. createProductsWorkflow: Create the Medusa product that the digital product is associated with its variant. Medusa provides this workflow through the @medusajs/medusa/core-flows package, which you can use as a step.
  2. createDigitalProductStep: Create the digital product.
  3. createDigitalProductMediasStep: Create the medias associated with the digital product.
  4. createRemoteLinkStep: Create the link between the digital product and the product variant. Medusa provides this step through the @medusajs/medusa/core-flows package.

You’ll implement the second and third steps.

createDigitalProductStep (Second Step)#

Create the file src/workflows/create-digital-product/steps/create-digital-product.ts with the following content:

src/workflows/create-digital-product/steps/create-digital-product.ts
6import { DIGITAL_PRODUCT_MODULE } from "../../../modules/digital-product"7
8export type CreateDigitalProductStepInput = {9  name: string10}11
12const createDigitalProductStep = createStep(13  "create-digital-product-step",14  async (data: CreateDigitalProductStepInput, { container }) => {15    const digitalProductModuleService: DigitalProductModuleService = 16      container.resolve(DIGITAL_PRODUCT_MODULE)17
18    const digitalProduct = await digitalProductModuleService19      .createDigitalProducts(data)20    21    return new StepResponse({22      digital_product: digitalProduct,23    }, {24      digital_product: digitalProduct,25    })26  },27  async ({ digital_product }, { container }) => {28    const digitalProductModuleService: DigitalProductModuleService = 29      container.resolve(DIGITAL_PRODUCT_MODULE)30    31    await digitalProductModuleService.deleteDigitalProducts(32      digital_product.id33    )34  }35)36
37export default createDigitalProductStep

This creates the createDigitalProductStep. In this step, you create a digital product.

In the compensation function, which is executed if an error occurs in the workflow, you delete the digital products.

createDigitalProductMediasStep (Third Step)#

Create the file src/workflows/create-digital-product/steps/create-digital-product-medias.ts with the following content:

src/workflows/create-digital-product/steps/create-digital-product-medias.ts
7import { MediaType } from "../../../modules/digital-product/types"8
9export type CreateDigitalProductMediaInput = {10  type: MediaType11  fileId: string12  mimeType: string13  digital_product_id: string14}15
16type CreateDigitalProductMediasStepInput = {17  medias: CreateDigitalProductMediaInput[]18}19
20const createDigitalProductMediasStep = createStep(21  "create-digital-product-medias",22  async ({ 23    medias,24  }: CreateDigitalProductMediasStepInput, { container }) => {25    const digitalProductModuleService: DigitalProductModuleService = 26      container.resolve(DIGITAL_PRODUCT_MODULE)27
28    const digitalProductMedias = await digitalProductModuleService29      .createDigitalProductMedias(medias)30
31    return new StepResponse({32      digital_product_medias: digitalProductMedias,33    }, {34      digital_product_medias: digitalProductMedias,35    })36  },37  async ({ digital_product_medias }, { container }) => {38    const digitalProductModuleService: DigitalProductModuleService = 39      container.resolve(DIGITAL_PRODUCT_MODULE)40    41    await digitalProductModuleService.deleteDigitalProductMedias(42      digital_product_medias.map((media) => media.id)43    )44  }45)46
47export default createDigitalProductMediasStep

This creates the createDigitalProductMediasStep. In this step, you create medias of the digital product.

In the compensation function, you delete the digital product medias.

Create createDigitalProductWorkflow#

Finally, create the file src/workflows/create-digital-product/index.ts with the following content:

src/workflows/create-digital-product/index.ts
22import { DIGITAL_PRODUCT_MODULE } from "../../modules/digital-product"23
24type CreateDigitalProductWorkflowInput = {25  digital_product: CreateDigitalProductStepInput & {26    medias: Omit<CreateDigitalProductMediaInput, "digital_product_id">[]27  }28  product: CreateProductWorkflowInputDTO29}30
31const createDigitalProductWorkflow = createWorkflow(32  "create-digital-product",33  (input: CreateDigitalProductWorkflowInput) => {34    const { medias, ...digitalProductData } = input.digital_product35
36    const product = createProductsWorkflow.runAsStep({37      input: {38        products: [input.product],39      },40    })41
42    const { digital_product } = createDigitalProductStep(43      digitalProductData44    )45
46    const { digital_product_medias } = createDigitalProductMediasStep(47      transform({48        digital_product,49        medias,50      },51      (data) => ({52        medias: data.medias.map((media) => ({53          ...media,54          digital_product_id: data.digital_product.id,55        })),56      })57      )58    )59
60    createRemoteLinkStep([{61      [DIGITAL_PRODUCT_MODULE]: {62        digital_product_id: digital_product.id,63      },64      [Modules.PRODUCT]: {65        product_variant_id: product[0].variants[0].id,66      },67    }])68
69    return new WorkflowResponse({70      digital_product: {71        ...digital_product,72        medias: digital_product_medias,73      },74    })75  }76)77
78export default createDigitalProductWorkflow

This creates the createDigitalProductWorkflow. The workflow accepts as a parameter the digital product and the Medusa product to create.

In the workflow, you run the following steps:

  1. createProductsWorkflow as a step to create a Medusa product.
  2. createDigitalProductStep to create the digital product.
  3. createDigitalProductMediasStep to create the digital product’s medias.
  4. createRemoteLinkStep to link the digital product to the product variant.

You’ll test out the workflow in the next section.

Further Reads#


Step 7: Create Digital Product API Route#

In this step, you’ll add the API route to create a digital product using the createDigitalProductWorkflow.

In the file src/api/admin/digital-products/route.ts add a new route handler:

src/api/admin/digital-products/route.ts
1// other imports...2import { z } from "zod"3import createDigitalProductWorkflow from "../../../workflows/create-digital-product"4import { CreateDigitalProductMediaInput } from "../../../workflows/create-digital-product/steps/create-digital-product-medias"5import { createDigitalProductsSchema } from "../../validation-schemas"6
7// ...8
9type CreateRequestBody = z.infer<10  typeof createDigitalProductsSchema11>12
13export const POST = async (14  req: AuthenticatedMedusaRequest<CreateRequestBody>,15  res: MedusaResponse16) => {17  const { result } = await createDigitalProductWorkflow(18    req.scope19  ).run({20    input: {21      digital_product: {22        name: req.validatedBody.name,23        medias: req.validatedBody.medias.map((media) => ({24          fileId: media.file_id,25          mimeType: media.mime_type,26          ...media,27        })) as Omit<CreateDigitalProductMediaInput, "digital_product_id">[],28      },29      product: req.validatedBody.product,30    },31  })32
33  res.json({34    digital_product: result.digital_product,35  })36}

This adds a POST API route at /admin/digital-products. In the route handler, you execute the createDigitalProductWorkflow created in the previous step, passing data from the request body as input.

The route handler imports a validation schema from a validation-schema file. So, create the file src/api/validation-schemas.ts with the following content:

src/api/validation-schemas.ts
1import { 2  AdminCreateProduct,3} from "@medusajs/medusa/api/admin/products/validators"4import { z } from "zod"5import { MediaType } from "../modules/digital-product/types"6
7export const createDigitalProductsSchema = z.object({8  name: z.string(),9  medias: z.array(z.object({10    type: z.nativeEnum(MediaType),11    file_id: z.string(),12    mime_type: z.string(),13  })),14  product: AdminCreateProduct(),15})

This defines the expected request body schema.

Finally, 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 { createDigitalProductsSchema } from "./validation-schemas"6
7export default defineMiddlewares({8  routes: [9    {10      matcher: "/admin/digital-products",11      method: "POST",12      middlewares: [13        validateAndTransformBody(createDigitalProductsSchema),14      ],15    },16  ],17})

This adds a validation middleware to ensure that the body of POST requests sent to /admin/digital-products match the createDigitalProductsSchema.

Further Read#


Step 8: Upload Digital Product Media API Route#

To upload the digital product media files, use Medusa’s File Module.

NoteYour Medusa application uses the local file module provider by default, which uploads files to a local directory. However, you can use other file module providers, such as the S3 module provider.

In this step, you’ll create an API route for uploading preview and main digital product media files.

Before creating the API route, install the multer express middleware to support file uploads:

Then, create the file src/api/admin/digital-products/upload/[type]/route.ts with the following content:

src/api/admin/digital-products/upload/[type]/route.ts
6import { MedusaError } from "@medusajs/framework/utils"7
8export const POST = async (9  req: AuthenticatedMedusaRequest,10  res: MedusaResponse11) => {12  const access = req.params.type === "main" ? "private" : "public"13  const input = req.files as Express.Multer.File[]14
15  if (!input?.length) {16    throw new MedusaError(17      MedusaError.Types.INVALID_DATA,18      "No files were uploaded"19    )20  }21
22  const { result } = await uploadFilesWorkflow(req.scope).run({23    input: {24      files: input?.map((f) => ({25        filename: f.originalname,26        mimeType: f.mimetype,27        content: f.buffer.toString("binary"),28        access,29      })),30    },31  })32
33  res.status(200).json({ files: result })34}

This adds a POST API route at /admin/digital-products/upload/[type] where [type] is either preview or main.

In the route handler, you use uploadFilesWorkflow from Medusa's core workflows to upload the file. If the file type is main, it’s uploaded with private access, as only customers who purchased it can download it. Otherwise, it’s uploaded with public access.

Next, add to the file src/api/middlewares.ts the multer middleware on this API route:

src/api/middlewares.ts
1// other imports...2import multer from "multer"3
4const upload = multer({ storage: multer.memoryStorage() })5
6export default defineMiddlewares({7  routes: [8    // ...9    {10      matcher: "/admin/digital-products/upload**",11      method: "POST",12      middlewares: [13        upload.array("files"),14      ],15    },16  ],17})

You’ll test out this API route in the next step as you use these API routes in the admin customizations.


Step 9: Add Digital Products UI Route in Admin#

The Medusa Admin is customizable, allowing you to inject widgets into existing pages or add UI routes to create new pages.

In this step, you’ll add a UI route to the Medusa Admin that displays a list of digital products.

Before you create the UI route, create the file src/admin/types/index.ts that holds the following types:

src/admin/types/index.ts
1import { ProductVariantDTO } from "@medusajs/framework/types"2
3export enum MediaType {4  MAIN = "main",5  PREVIEW = "preview"6}7
8export type DigitalProductMedia = {9  id: string10  type: MediaType11  fileId: string12  mimeType: string13  digitalProducts?: DigitalProduct14}15
16export type DigitalProduct = {17  id: string18  name: string19  medias?: DigitalProductMedia[]20  product_variant?:ProductVariantDTO21}

These types will be used by the UI route.

Next, create the file src/admin/routes/digital-products/page.tsx with the following content:

src/admin/routes/digital-products/page.tsx
6import { DigitalProduct } from "../../types"7
8const DigitalProductsPage = () => {9  const [digitalProducts, setDigitalProducts] = useState<10    DigitalProduct[]11  >([])12  // TODO fetch digital products...13
14  return (15    <Container>16      <div className="flex justify-between items-center mb-4">17        <Heading level="h2">Digital Products</Heading>18        {/* TODO add create button */}19      </div>20      <Table>21        <Table.Header>22          <Table.Row>23            <Table.HeaderCell>Name</Table.HeaderCell>24            <Table.HeaderCell>Action</Table.HeaderCell>25          </Table.Row>26        </Table.Header>27        <Table.Body>28          {digitalProducts.map((digitalProduct) => (29            <Table.Row key={digitalProduct.id}>30              <Table.Cell>31                {digitalProduct.name}32              </Table.Cell>33              <Table.Cell>34                <Link to={`/products/${digitalProduct.product_variant.product_id}`}>35                  View Product36                </Link>37              </Table.Cell>38            </Table.Row>39          ))}40        </Table.Body>41      </Table>42      {/* TODO add pagination component */}43    </Container>44  )45}46
47export const config = defineRouteConfig({48  label: "Digital Products",49  icon: PhotoSolid,50})51
52export default DigitalProductsPage

This creates a UI route that's displayed at the /digital-products path in the Medusa Admin. The UI route also adds a sidebar item with the label “Digital Products" pointing to the UI route.

In the React component of the UI route, you just display the table of digital products.

Next, replace the first TODO with the following:

src/admin/routes/digital-products/page.tsx
1// other imports...2import { useMemo } from "react"3
4const DigitalProductsPage = () => {5  // ...6    7  const [currentPage, setCurrentPage] = useState(0)8  const pageLimit = 209  const [count, setCount] = useState(0)10  const pagesCount = useMemo(() => {11    return count / pageLimit12  }, [count])13  const canNextPage = useMemo(14    () => currentPage < pagesCount - 1, 15    [currentPage, pagesCount]16  )17  const canPreviousPage = useMemo(18    () => currentPage > 0, 19    [currentPage]20  )21
22  const nextPage = () => {23    if (canNextPage) {24      setCurrentPage((prev) => prev + 1)25    }26  }27
28  const previousPage = () => {29    if (canPreviousPage) {30      setCurrentPage((prev) => prev - 1)31    }32  }33  34  // TODO fetch digital products35    36  // ...37}

This defines the following pagination variables:

  1. currentPage: The number of the current page.
  2. pageLimit: The number of digital products to show per page.
  3. count: The total count of digital products.
  4. pagesCount: A memoized variable that holds the number of pages based on count and pageLimit.
  5. canNextPage: A memoized variable that indicates whether there’s a next page based on whether the current page is less than pagesCount - 1.
  6. canPreviousPage: A memoized variable that indicates whether there’s a previous pages based on whether the current page is greater than 0.
  7. nextPage: A function that increments the currentPage.
  8. previousPage: A function that decrements the currentPage.

Then, replace the new TODO fetch digital products with the following:

src/admin/routes/digital-products/page.tsx
1// other imports2import { useEffect } from "react"3
4const DigitalProductsPage = () => {5  // ...6    7  const fetchProducts = () => {8    const query = new URLSearchParams({9      limit: `${pageLimit}`,10      offset: `${pageLimit * currentPage}`,11    })12    13    fetch(`/admin/digital-products?${query.toString()}`, {14      credentials: "include",15    })16    .then((res) => res.json())17    .then(({ 18      digital_products: data, 19      count,20    }) => {21      setDigitalProducts(data)22      setCount(count)23    })24  }25
26  useEffect(() => {27    fetchProducts()28  }, [currentPage])29    30  // ...31}

This defines a fetchProducts function that fetches the digital products using the API route you created in step 4. You also call that function within a useEffect callback which is executed whenever the currentPage changes.

Finally, replace the TODO add pagination component in the return statement with Table.Pagination component:

src/admin/routes/digital-products/page.tsx
1return (2    <Container>3      {/* ... */}4      <Table.Pagination5        count={count}6        pageSize={pageLimit}7        pageIndex={currentPage}8        pageCount={pagesCount}9        canPreviousPage={canPreviousPage}10        canNextPage={canNextPage}11        previousPage={previousPage}12        nextPage={nextPage}13      />14    </Container>15  )

The Table.Pagination component accepts as props the pagination variables you defined earlier.

Test UI Route#

To test the UI route out, start the Medusa application, go to localhost:9000/app, and log in as an admin user.

Once you log in, you’ll find a new sidebar item, “Digital Products.” If you click on it, you’ll see the UI route you created with a table of digital products.

Further Reads#


Step 10: Add Create Digital Product Form in Admin#

In this step, you’ll add a form for admins to create digital products. The form opens in a drawer or side window from within the Digital Products UI route you created in the previous section.

Create the file src/admin/components/create-digital-product-form/index.tsx with the following content:

src/admin/components/create-digital-product-form/index.tsx
1import { useState } from "react"2import { Input, Button, Select, toast } from "@medusajs/ui"3import { MediaType } from "../../types"4
5type CreateMedia = {6  type: MediaType7  file?: File8}9
10type Props = {11  onSuccess?: () => void12}13
14const CreateDigitalProductForm = ({15  onSuccess,16}: Props) => {17  const [name, setName] = useState("")18  const [medias, setMedias] = useState<CreateMedia[]>([])19  const [productTitle, setProductTitle] = useState("")20  const [loading, setLoading] = useState(false)21
22  const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {23    // TODO handle submit24  }25
26  return (27    <form onSubmit={onSubmit}>28      {/* TODO show form inputs */}29      <Button 30        type="submit"31        isLoading={loading}32      >33        Create34      </Button>35    </form>36  )37}38
39export default CreateDigitalProductForm

This creates a React component that shows a form and handles creating a digital product on form submission.

You currently don’t display the form. Replace the return statement with the following:

src/admin/components/create-digital-product-form/index.tsx
1return (2  <form onSubmit={onSubmit}>3    <Input4      name="name"5      placeholder="Name"6      type="text"7      value={name}8      onChange={(e) => setName(e.target.value)}9    />10    <fieldset className="my-4">11      <legend className="mb-2">Media</legend>12      <Button type="button" onClick={onAddMedia}>Add Media</Button>13      {medias.map((media, index) => (14        <fieldset className="my-2 p-2 border-solid border rounded">15          <legend>Media {index + 1}</legend>16          <Select 17            value={media.type} 18            onValueChange={(value) => changeFiles(19              index,20              {21                type: value as MediaType,22              }23            )}24          >25            <Select.Trigger>26              <Select.Value placeholder="Media Type" />27            </Select.Trigger>28            <Select.Content>29              <Select.Item value={MediaType.PREVIEW}>30                Preview31              </Select.Item>32              <Select.Item value={MediaType.MAIN}>33                Main34              </Select.Item>35            </Select.Content>36          </Select>37          <Input38            name={`file-${index}`}39            type="file"40            onChange={(e) => changeFiles(41              index,42              {43                file: e.target.files[0],44              }45            )}46            className="mt-2"47          />48        </fieldset>49      ))}50    </fieldset>51    <fieldset className="my-4">52      <legend className="mb-2">Product</legend>53      <Input54        name="product_title"55        placeholder="Product Title"56        type="text"57        value={productTitle}58        onChange={(e) => setProductTitle(e.target.value)}59      />60    </fieldset>61    <Button 62      type="submit"63      isLoading={loading}64    >65      Create66    </Button>67  </form>68)

This shows input fields for the digital product and product’s names. It also shows a fieldset of media files, with the ability to add more media files on a button click.

Add in the component the onAddMedia function that is triggered by a button click to add a new media:

src/admin/components/create-digital-product-form/index.tsx
1const onAddMedia = () => {2  setMedias((prev) => [3    ...prev,4    {5      type: MediaType.PREVIEW,6    },7  ])8}

And add in the component a changeFiles function that saves changes related to a media in the medias state variable:

src/admin/components/create-digital-product-form/index.tsx
1const changeFiles = (2  index: number,3  data: Partial<CreateMedia>4) => {5  setMedias((prev) => [6    ...(prev.slice(0, index)),7    {8      ...prev[index],9      ...data,10    },11    ...(prev.slice(index + 1)),12  ])13}

On submission, the media files should first be uploaded before the digital product is created.

So, add before the onSubmit function the following new function:

src/admin/components/create-digital-product-form/index.tsx
1const uploadMediaFiles = async (2  type: MediaType3) => {4  const formData = new FormData()5  const mediaWithFiles = medias.filter(6    (media) => media.file !== undefined && 7      media.type === type8  )9
10  if (!mediaWithFiles.length) {11    return12  }13
14  mediaWithFiles.forEach((media) => {15    formData.append("files", media.file)16  })17
18  const { files } = await fetch(`/admin/digital-products/upload/${type}`, {19    method: "POST",20    credentials: "include",21    body: formData,22  }).then((res) => res.json())23
24  return {25    mediaWithFiles,26    files,27  }28}

This function accepts a type of media to upload (preview or main). In the function, you upload the files of the specified type using the API route you created in step 7. You return the uploaded files and their associated media.

Next, you’ll implement the onSubmit function. Replace it with the following:

src/admin/components/create-digital-product-form/index.tsx
1const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {2  e.preventDefault()3  setLoading(true)4
5  try {6    const {7      mediaWithFiles: previewMedias,8      files: previewFiles,9    } = await uploadMediaFiles(MediaType.PREVIEW) || {}10    const {11      mediaWithFiles: mainMedias,12      files: mainFiles,13    } = await uploadMediaFiles(MediaType.MAIN) || {}14
15    const mediaData = []16
17    previewMedias?.forEach((media, index) => {18      mediaData.push({19        type: media.type,20        file_id: previewFiles[index].id,21        mime_type: media.file!.type,22      })23    })24
25    mainMedias?.forEach((media, index) => {26      mediaData.push({27        type: media.type,28        file_id: mainFiles[index].id,29        mime_type: media.file!.type,30      })31    })32
33    // TODO create digital product34  } catch (e) {35    console.error(e)36    setLoading(false)37  }38}

In this function, you use the uploadMediaFiles function to upload preview and main media files. Then, you prepare the media data that’ll be used when creating the digital product in a mediaData variable.

NoteNotice that you use the id of uploaded files, as returned in the response of /admin/digital-products/upload/[type] as the file_id value of the media to be created.

Finally, replace the new TODO in onSubmit with the following:

src/admin/components/create-digital-product-form/index.tsx
1fetch(`/admin/digital-products`, {2  method: "POST",3  credentials: "include",4  headers: {5    "Content-Type": "application/json",6  },7  body: JSON.stringify({8    name,9    medias: mediaData,10    product: {11      title: productTitle,12      options: [{13        title: "Default",14        values: ["default"],15      }],16      variants: [{17        title: productTitle,18        options: {19          Default: "default",20        },21        // delegate setting the prices to the22        // product's page.23        prices: [],24      }],25    },26  }),27})28.then((res) => res.json())29.then(({ message }) => {30  if (message) {31    throw message32  }33  onSuccess?.()34})35.catch((e) => {36  console.error(e)37  toast.error("Error", {38    description: `An error occurred while creating the digital product: ${e}`,39  })40})41.finally(() => setLoading(false))

In this snippet, you send a POST request to /admin/digital-products to create a digital product.

You’ll make changes now to src/admin/routes/digital-products/page.tsx to show the form.

First, add a new open state variable:

src/admin/routes/digital-products/page.tsx
1const DigitalProductsPage = () => {2  const [open, setOpen] = useState(false)3  // ...4}

Then, replace the TODO add create button in the return statement to show the CreateDigitalProductForm component:

src/admin/routes/digital-products/page.tsx
1// other imports...2import { Drawer } from "@medusajs/ui"3import CreateDigitalProductForm from "../../components/create-digital-product-form"4
5const DigitalProductsPage = () => {6  // ...7    8  return (9    <Container>10      {/* Replace the TODO with the following */}11      <Drawer open={open} onOpenChange={(openChanged) => setOpen(openChanged)}>12        <Drawer.Trigger 13          onClick={() => {14            setOpen(true)15          }}16          asChild17        >18          <Button>Create</Button>19        </Drawer.Trigger>20        <Drawer.Content>21          <Drawer.Header>22            <Drawer.Title>Create Product</Drawer.Title>23          </Drawer.Header>24          <Drawer.Body>25            <CreateDigitalProductForm onSuccess={() => {26              setOpen(false)27              if (currentPage === 0) {28                fetchProducts()29              } else {30                setCurrentPage(0)31              }32            }} />33          </Drawer.Body>34        </Drawer.Content>35      </Drawer>36    </Container>37  )38}

This adds a Create button in the Digital Products UI route and, when it’s clicked, shows the form in a drawer or side window.

You pass to the CreateDigitalProductForm component an onSuccess prop that, when the digital product is created successfully, re-fetches the digital products.

Test Create Form Out#

To test the form, open the Digital Products page in the Medusa Admin. There, you’ll find a new Create button.

If you click on the button, a form will open in a drawer. Fill in the details of the digital product to create one.

After you create the digital product, you’ll find it in the table. You can also click on View Product to edit the product’s details, such as the variant’s price.

Tip

To use this digital product in later steps (such as to create an order), you must make the following changes to its associated product details:

  1. Change the status to published.
  2. Add it to the default sales channel.
  3. Disable manage inventory of the variant.
  4. Add prices to the variant.

Step 11: Create Digital Product Fulfillment Module Provider#

In this step, you'll create a fulfillment module provider for digital products. It doesn't have any real fulfillment functionality as digital products aren't physically fulfilled.

Create Module Provider Service#

Start by creating the src/modules/digital-product-fulfillment directory.

Then, create the file src/modules/digital-product-fulfillment/service.ts with the following content:

src/modules/digital-product-fulfillment/service.ts
1import { AbstractFulfillmentProviderService } from "@medusajs/framework/utils"2import { FulfillmentOption } from "@medusajs/framework/types"3
4class DigitalProductFulfillmentService extends AbstractFulfillmentProviderService {5  static identifier = "digital"6
7  constructor() {8    super()9  }10
11  async getFulfillmentOptions(): Promise<FulfillmentOption[]> {12    return [13      {14        id: "digital-fulfillment",15      },16    ]17  }18
19  async validateFulfillmentData(20    optionData: Record<string, unknown>,21    data: Record<string, unknown>,22    context: Record<string, unknown>23  ): Promise<any> {24    return data25  }26
27  async validateOption(data: Record<string, any>): Promise<boolean> {28    return true29  }30
31  async createFulfillment(): Promise<Record<string, any>> {32    // No data is being sent anywhere33    return {}34  }35
36  async cancelFulfillment(): Promise<any> {37    return {}38  }39
40  async createReturnFulfillment(): Promise<any> {41    return {}42  }43}44
45export default DigitalProductFulfillmentService

The fulfillment provider registers one fulfillment option, and doesn't perform actual fulfillment.

Create Module Provider Definition#

Then, create the module provider's definition in the file src/modules/digital-product-fulfillment/index.ts:

src/modules/digital-product-fulfillment/index.ts
1import { ModuleProviderExports } from "@medusajs/framework/types"2import DigitalProductFulfillmentService from "./service"3
4const services = [DigitalProductFulfillmentService]5
6const providerExport: ModuleProviderExports = {7  services,8}9
10export default providerExport

Register Module Provider in Medusa's Configurations#

Finally, register the module provider in medusa-config.ts:

medusa-config.ts
1// other imports...2import { Modules } from "@medusajs/framework/utils"3
4module.exports = defineConfig({5  modules: [6    // ...7    {8      resolve: "@medusajs/medusa/fulfillment",9      options: {10        providers: [11          {12            resolve: "@medusajs/medusa/fulfillment-manual",13            id: "manual",14          },15          {16            resolve: "./src/modules/digital-product-fulfillment",17            id: "digital",18          },19        ],20      },21    },22  ],23})

This registers the digital product fulfillment as a module provider of the Fulfillment Module.

Add Fulfillment Provider to Location#

In the Medusa Admin, go to Settings -> Location & Shipping, and add the fulfillment provider and a shipping option for it in a location.

This is necessary to use the fulfillment provider's shipping option during checkout.


Step 12: Customize Cart Completion#

In this step, you’ll customize the cart completion flow to not only create a Medusa order, but also create a digital product order.

To customize the cart completion flow, you’ll create a workflow and then use that workflow in an API route defined at src/api/store/carts/[id]/complete/route.ts.

The workflow has the following steps:

  1. completeCartWorkflow to create a Medusa order from the cart. Medusa provides this workflow through the @medusajs/medusa/core-flows package and you can use it as a step.
  2. useQueryGraphStep to retrieve the order’s items with the digital products associated with the purchased product variants. Medusa provides this step through the @medusajs/medusa/core-flows package.
  3. If the order has digital products, you:
    1. create the digital product order.
    2. link the digital product order with the Medusa order. Medusa provides a createRemoteLinkStep in the @medusajs/medusa/core-flows package that can be used here.
    3. Create a fulfillment for the digital products in the order. Medusa provides a createOrderFulfillmentWorkflow in the @medusajs/medusa/core-flows package that you can use as a step here.
    4. Emit the digital_product_order.created custom event to handle it later in a subscriber and send the customer an email. Medusa provides a emitEventStep in the @medusajs/medusa/core-flows that you can use as a step here.

You’ll only implement the 3.a step of the workflow.

createDigitalProductOrderStep (Step 3.a)#

Create the file src/workflows/create-digital-product-order/steps/create-digital-product-order.ts with the following content:

src/workflows/create-digital-product-order/steps/create-digital-product-order.ts
13import DigitalProduct from "../../../modules/digital-product/models/digital-product"14
15type StepInput = {16  items: (OrderLineItemDTO & {17    variant: ProductVariantDTO & {18      digital_product: InferTypeOf<typeof DigitalProduct>19    }20  })[]21}22
23const createDigitalProductOrderStep = createStep(24  "create-digital-product-order",25  async ({ items }: StepInput, { container }) => {26    const digitalProductModuleService: DigitalProductModuleService = 27      container.resolve(DIGITAL_PRODUCT_MODULE)28
29    const digitalProductIds = items.map((item) => item.variant.digital_product.id)30
31    const digitalProductOrder = await digitalProductModuleService32      .createDigitalProductOrders({33        status: OrderStatus.PENDING,34        products: digitalProductIds,35      })36
37    return new StepResponse({38      digital_product_order: digitalProductOrder,39    }, {40      digital_product_order: digitalProductOrder,41    })42  },43  async ({ digital_product_order }, { container }) => {44    const digitalProductModuleService: DigitalProductModuleService = 45      container.resolve(DIGITAL_PRODUCT_MODULE)46
47    await digitalProductModuleService.deleteDigitalProductOrders(48      digital_product_order.id49    )50  }51)52
53export default createDigitalProductOrderStep

This creates the createDigitalProductOrderStep. In this step, you create a digital product order.

In the compensation function, you delete the digital product order.

Create createDigitalProductOrderWorkflow#

Create the file src/workflows/create-digital-product-order/index.ts with the following content:

src/workflows/create-digital-product-order/index.ts
16} from "@medusajs/framework/utils"17import createDigitalProductOrderStep from "./steps/create-digital-product-order"18import { DIGITAL_PRODUCT_MODULE } from "../../modules/digital-product"19
20type WorkflowInput = {21  cart_id: string22}23
24const createDigitalProductOrderWorkflow = createWorkflow(25  "create-digital-product-order",26  (input: WorkflowInput) => {27    const { id } = completeCartWorkflow.runAsStep({28      input: {29        id: input.cart_id,30      },31    })32
33    const { data: orders } = useQueryGraphStep({34      entity: "order",35      fields: [36        "*",37        "items.*",38        "items.variant.*",39        "items.variant.digital_product.*",40      ],41      filters: {42        id,43      },44      options: {45        throwIfKeyNotFound: true,46      },47    })48
49    const itemsWithDigitalProducts = transform({50      orders,51    },52    (data) => {53        return data.orders[0].items.filter((item) => item.variant.digital_product !== undefined)54      }55    )56
57    const digital_product_order = when(58      "create-digital-product-order-condition", 59      itemsWithDigitalProducts, 60      (itemsWithDigitalProducts) => {61        return itemsWithDigitalProducts.length62      }63    ).then(() => {64      const { 65        digital_product_order,66      } = createDigitalProductOrderStep({67        items: orders[0].items,68      })69  70      createRemoteLinkStep([{71        [DIGITAL_PRODUCT_MODULE]: {72          digital_product_order_id: digital_product_order.id,73        },74        [Modules.ORDER]: {75          order_id: id,76        },77      }])78
79      createOrderFulfillmentWorkflow.runAsStep({80        input: {81          order_id: id,82          items: transform({83            itemsWithDigitalProducts,84          }, (data) => {85            return data.itemsWithDigitalProducts.map((item) => ({86              id: item.id,87              quantity: item.quantity,88            }))89          }),90        },91      })92  93      emitEventStep({94        eventName: "digital_product_order.created",95        data: {96          id: digital_product_order.id,97        },98      })99
100      return digital_product_order101    })102
103    return new WorkflowResponse({104      order: orders[0],105      digital_product_order,106    })107  }108)109
110export default createDigitalProductOrderWorkflow

This creates the workflow createDigitalProductOrderWorkflow. It runs the following steps:

  1. completeCartWorkflow as a step to create the Medusa order.
  2. useQueryGraphStep to retrieve the order’s items with their associated variants and linked digital products.
  3. Use when to check whether the order has digital products. If so:
    1. Use the createDigitalProductOrderStep to create the digital product order.
    2. Use the createRemoteLinkStep to link the digital product order to the Medusa order.
    3. Use the createOrderFulfillmentWorkflow to create a fulfillment for the digital products in the order.
    4. Use the emitEventStep to emit a custom event.

The workflow returns the Medusa order and the digital product order, if created.

Cart Completion API Route#

Next, create the file src/api/store/carts/[id]/complete/route.ts with the following content:

src/api/store/carts/[id]/complete/route.ts
1import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"2import createDigitalProductOrderWorkflow from "../../../../../workflows/create-digital-product-order"3
4export const POST = async (5  req: MedusaRequest,6  res: MedusaResponse7) => {8  const { result } = await createDigitalProductOrderWorkflow(req.scope)9    .run({10      input: {11        cart_id: req.params.id,12      },13    })14
15  res.json({16    type: "order",17    ...result,18  })19}

This overrides the Cart Completion API route. In the route handler, you execute the createDigitalProductOrderWorkflow and return the created order in the response.

Test Cart Completion#

To test out the cart completion, it’s recommended to use the Next.js Starter storefront to place an order.

Once you place the order, the cart completion route you added above will run, creating the order and digital product order, if the order has digital products.

In a later step, you’ll add an API route to allow customers to view and download their purchased digital products.

Further Read#


Step 13: Fulfill Digital Order Workflow#

In this step, you'll create a workflow that fulfills a digital order by sending a notification to the customer. Later, you'll execute this workflow in a subscriber that listens to the digital_product_order.created event.

The workflow has the following steps:

  1. Retrieve the digital product order's details. For this, you'll use useQueryGraphStep from Medusa's core workflows.
  2. Send a notification to the customer with the digital products to download.

So, you only need to implement the second step.

Add Types#

Before creating the step, add to src/modules/digital-product/types/index.ts the following:

Code
1import { OrderDTO, InferTypeOf } from "@medusajs/framework/types"2import DigitalProductOrder from "../models/digital-product-order"3
4// ...5
6export type DigitalProductOrder = 7  InferTypeOf<typeof DigitalProductOrder> & {8    order?: OrderDTO9  }

This adds a type for a digital product order, which you'll use next.

You use InferTypeOf to infer the type of the DigitalProductOrder data model, and add to it the optional order property, which is the linked order.

Create sendDigitalOrderNotificationStep#

To create the step, create the file src/workflows/fulfill-digital-order/steps/send-digital-order-notification.ts with the following content:

src/workflows/fulfill-digital-order/steps/send-digital-order-notification.ts
10import { DigitalProductOrder, MediaType } from "../../../modules/digital-product/types"11
12type SendDigitalOrderNotificationStepInput = {13  digital_product_order: DigitalProductOrder14}15
16export const sendDigitalOrderNotificationStep = createStep(17  "send-digital-order-notification",18  async ({ 19    digital_product_order: digitalProductOrder, 20  }: SendDigitalOrderNotificationStepInput, 21  { container }) => {22    const notificationModuleService: INotificationModuleService = container23    .resolve(ModuleRegistrationName.NOTIFICATION)24    const fileModuleService: IFileModuleService = container.resolve(25      ModuleRegistrationName.FILE26    )27
28    // TODO assemble notification29  }30)

This creates the sendDigitalOrderNotificationStep step that receives a digital product order as an input.

In the step, so far you resolve the main services of the Notification and File Modules.

Replace the TODO with the following:

src/workflows/fulfill-digital-order/steps/send-digital-order-notification.ts
1const notificationData = await Promise.all(2  digitalProductOrder.products.map(async (product) => {3    const medias = []4
5    await Promise.all(6      product.medias7      .filter((media) => media.type === MediaType.MAIN)8      .map(async (media) => {9        medias.push(10          (await fileModuleService.retrieveFile(media.fileId)).url11        )12      })13    )14
15    return {16      name: product.name,17      medias,18    }19  })20)21
22// TODO send notification

In this snippet, you put together the data to send in the notification. You loop over the digital products in the order and retrieve the URL of their main files using the File Module.

Finally, replace the new TODO with the following:

src/workflows/fulfill-digital-order/steps/send-digital-order-notification.ts
1const notification = await notificationModuleService.createNotifications({2  to: digitalProductOrder.order.email,3  template: "digital-order-template",4  channel: "email",5  data: {6    products: notificationData,7  },8})9
10return new StepResponse(notification)

You use the createNotifications method of the Notification Module's main service to send an email using the installed provider.

Create Workflow#

Create the workflow in the file src/workflows/fulfill-digital-order/index.ts:

src/workflows/fulfill-digital-order/index.ts
9
10type FulfillDigitalOrderWorkflowInput = {11  id: string12}13
14export const fulfillDigitalOrderWorkflow = createWorkflow(15  "fulfill-digital-order",16  ({ id }: FulfillDigitalOrderWorkflowInput) => {17    const { data: digitalProductOrders } = useQueryGraphStep({18      entity: "digital_product_order",19      fields: [20        "*",21        "products.*",22        "products.medias.*",23        "order.*",24      ],25      filters: {26        id,27      },28      options: {29        throwIfKeyNotFound: true,30      },31    })32
33    sendDigitalOrderNotificationStep({34      digital_product_order: digitalProductOrders[0],35    })36
37    return new WorkflowResponse(38      digitalProductOrders[0]39    )40  }41)

In the workflow, you:

  1. Retrieve the digital product order's details using useQueryGraphStep from Medusa's core workflows.
  2. Send a notification to the customer with the digital product download links using the sendDigitalOrderNotificationStep.

Configure Notification Module Provider#

In the sendDigitalOrderNotificationStep, you use a notification provider configured for the email channel to send the notification.

Check out the Integrations page to find Notification Module Providers.

For testing purposes, add to medusa-config.ts the following to use the Local Notification Module Provider:

medusa-config.ts
1module.exports = defineConfig({2  // ...3  modules: [4    // ...5    {6      resolve: "@medusajs/medusa/notification",7      options: {8        providers: [9          {10            resolve: "@medusajs/medusa/notification-local",11            id: "local",12            options: {13              name: "Local Notification Provider",14              channels: ["email"],15            },16          },17        ],18      },19    },20  ],21})

Step 14: Handle the Digital Product Order Event#

A subscriber is an asynchronous function that, when an event is emitted, is executed. You can implement in subscribers features that aren't essential to the original flow that emitted the event.

In this step, you'll create a subscriber that listens to the digital_product_order.created event and executes the workflow from the above step.

Create the file src/subscribers/handle-digital-order.ts with the following content:

src/subscribers/handle-digital-order.ts
7} from "../workflows/fulfill-digital-order"8
9async function digitalProductOrderCreatedHandler({10  event: { data },11  container,12}: SubscriberArgs<{ id: string }>) {13  await fulfillDigitalOrderWorkflow(container).run({14    input: {15      id: data.id,16    },17  })18}19
20export default digitalProductOrderCreatedHandler21
22export const config: SubscriberConfig = {23  event: "digital_product_order.created",24}

This adds a subscriber that listens to the digital_product_order.created event. It executes the fulfillDigitalOrderWorkflow to send the customer an email and mark the order's fulfillment as fulfilled.

Test Subscriber Out#

To test out the subscriber, place an order with digital products. This triggers the digital_product_order.created event which executes the subscriber.


Step 15: Create Store API Routes#

In this step, you’ll create three store API routes:

  1. Retrieve the preview files of a digital product. This is useful when the customer is browsing the products before purchase.
  2. List the digital products that the customer has purchased.
  3. Get the download link to a media of the digital product that the customer purchased.

Retrieve Digital Product Previews API Route#

Create the file src/api/store/digital-products/[id]/preview/route.ts with the following content:

src/api/store/digital-products/[id]/preview/route.ts
14} from "../../../../../modules/digital-product/types"15
16export const GET = async (17  req: MedusaRequest,18  res: MedusaResponse19) => {20  const fileModuleService = req.scope.resolve(21    Modules.FILE22  )23
24  const digitalProductModuleService: DigitalProductModuleService = 25    req.scope.resolve(26      DIGITAL_PRODUCT_MODULE27    )28  29  const medias = await digitalProductModuleService.listDigitalProductMedias({30    digital_product_id: req.params.id,31    type: MediaType.PREVIEW,32  })33
34  const normalizedMedias = await Promise.all(35    medias.map(async (media) => {36      const { fileId, ...mediaData } = media37      const fileData = await fileModuleService.retrieveFile(fileId)38
39      return {40        ...mediaData,41        url: fileData.url,42      }43    })44  )45
46  res.json({47    previews: normalizedMedias,48  })49}

This adds a GET API route at /store/digital-products/[id]/preview, where [id] is the ID of the digital product to retrieve its preview media.

In the route handler, you retrieve the preview media of the digital product and then use the File Module’s service to get the URL of the preview file.

You return in the response the preview files.

List Digital Product Purchases API Route#

Create the file src/api/store/customers/me/digital-products/route.ts with the following content:

src/api/store/customers/me/digital-products/route.ts
7} from "@medusajs/framework/utils"8
9export const GET = async (10  req: AuthenticatedMedusaRequest,11  res: MedusaResponse12) => {13  const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)14
15  const { data: [customer] } = await query.graph({16    entity: "customer",17    fields: [18      "orders.digital_product_order.products.*",19      "orders.digital_product_order.products.medias.*",20    ],21    filters: {22      id: req.auth_context.actor_id,23    },24  })25
26  const digitalProducts = {}27
28  customer.orders.forEach((order) => {29    order.digital_product_order.products.forEach((product) => {30      digitalProducts[product.id] = product31    })32  })33
34  res.json({35    digital_products: Object.values(digitalProducts),36  })37}

This adds a GET API route at /store/customers/me/digital-products. All API routes under /store/customers/me require customer authentication.

In the route handler, you use Query to retrieve the customer’s orders and linked digital product orders, and return the purchased digital products in the response.

Get Digital Product Media Download URL API Route#

Create the file src/api/store/customers/me/digital-products/[mediaId]/download/route.ts with the following content:

src/api/store/customers/me/digital-products/[mediaId]/download/route.ts
9} from "@medusajs/framework/utils"10
11export const POST = async (12  req: AuthenticatedMedusaRequest,13  res: MedusaResponse14) => {15  const fileModuleService = req.scope.resolve(16    Modules.FILE17  )18  const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)19
20  const { data: [customer] } = await query.graph({21    entity: "customer",22    fields: [23      "orders.digital_product_order.*",24    ],25    filters: {26      id: req.auth_context.actor_id,27    },28  })29
30  const customerDigitalOrderIds = customer.orders31    .filter((order) => order.digital_product_order !== undefined)32    .map((order) => order.digital_product_order.id)33
34  const { data: dpoResult } = await query.graph({35    entity: "digital_product_order",36    fields: [37      "products.medias.*",38    ],39    filters: {40      id: customerDigitalOrderIds,41    },42  })43
44  if (!dpoResult.length) {45    throw new MedusaError(46      MedusaError.Types.NOT_ALLOWED,47      "Customer didn't purchase digital product."48    )49  }50
51  let foundMedia = undefined52
53  dpoResult[0].products.some((product) => {54    return product.medias.some((media) => {55      foundMedia = media.id === req.params.mediaId ? media : undefined56
57      return foundMedia !== undefined58    })59  })60
61  if (!foundMedia) {62    throw new MedusaError(63      MedusaError.Types.NOT_ALLOWED,64      "Customer didn't purchase digital product."65    )66  }67
68  const fileData = await fileModuleService.retrieveFile(foundMedia.fileId)69
70  res.json({71    url: fileData.url,72  })73}

This adds a POST API route at /store/customers/me/digital-products/[mediaId], where [mediaId] is the ID of the digital product media to download.

In the route handler, you retrieve the customer’s orders and linked digital orders, then check if the digital orders have the required media file. If not, an error is thrown.

If the media is found in th customer's previous purchases, you use the File Module’s service to retrieve the download URL of the media and return it in the response.

You’ll test out these API routes in the next step.

Further Reads#


Step 16: Customize Next.js Starter#

In this section, you’ll customize the Next.js Starter storefront to:

  1. Show a preview button on a digital product’s page to view the preview files.
  2. Add a new tab in the customer’s dashboard to view their purchased digital products.
  3. Allow customers to download the digital products through the new page in the dashboard.

If you haven't installed the Next.js Starter storefront in the first step, refer to this guide to learn how to install it.

Add Types#

In src/types/global.ts, add the following types that you’ll use in your customizations:

src/types/global.ts
1import { BaseProductVariant } from "@medusajs/framework/types/http/product/common"2
3// ...4
5export type DigitalProduct = {6  id: string7  name: string8  medias?: DigitalProductMedia[]9}10
11export type DigitalProductMedia = {12  id: string13  fileId: string14  type: "preview" | "main"15  mimeType: string16}17
18export type VariantWithDigitalProduct = BaseProductVariant & {19  digital_product?: DigitalProduct20}

Retrieve Digital Products with Variants#

To retrieve the digital products details when retrieving a product and its variants, in the src/lib/data/products.ts file, change the getProductsById and getProductByHandle functions to pass the digital products in the fields property passed to the sdk.store.product.list method:

src/lib/data/products.ts
1export const getProductsById = cache(async function ({2  ids,3  regionId,4}: {5  ids: string[]6  regionId: string7}) {8  return sdk.store.product9    .list(10      {11        // ...12        fields: "*variants.calculated_price,*variants.digital_product",13      }14      // ...15    )16    // ...17})18
19export const getProductByHandle = cache(async function (20  handle: string,21  regionId: string22) {23  return sdk.store.product24    .list(25      {26        // ...27        fields: "*variants.calculated_price,*variants.digital_product",28      }29      // ...30    )31    // ...32})

When a customer views a product’s details page, digital products linked to variants are also retrieved.

To retrieve the links of a digital product’s preview media, add in src/lib/data/products.ts the following function:

src/lib/data/products.ts
1export const getDigitalProductPreview = cache(async function ({2  id,3}: {4  id: string5}) {6  const { previews } = await fetch(7    `${process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL}/store/digital-products/${id}/preview`, {8    credentials: "include",9    headers: {10      "x-publishable-api-key": process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY,11    },12  }).then((res) => res.json())13
14  // for simplicity, return only the first preview url15  // instead you can show all the preview media to the customer16  return previews.length ? previews[0].url : ""17})

This function uses the API route you created in the previous section to get the preview links and return the first preview link.

Add Preview Button#

To add a button that shows the customer the preview media of a digital product, first, in src/modules/products/components/product-actions/index.tsx, cast the selectedVariant variable in the component to the VariantWithDigitalProduct type you created earlier:

src/modules/products/components/product-actions/index.tsx
1// other imports...2import { VariantWithDigitalProduct } from "../../../../types/global"3
4export default function ProductActions({5  product,6  region,7  disabled,8}: ProductActionsProps) {9
10  // ...11    12  const selectedVariant = useMemo(() => {13    // ...14  }, [product.variants, options]) as VariantWithDigitalProduct15    16  // ...17}

Then, add the following function in the component:

src/modules/products/components/product-actions/index.tsx
1// other imports...2import { getDigitalProductPreview } from "../../../../lib/data/products"3
4export default function ProductActions({5  product,6  region,7  disabled,8}: ProductActionsProps) {9  // ...10    11  const handleDownloadPreview = async () => {12    if (!selectedVariant?.digital_product) {13      return14    }15
16    const downloadUrl = await getDigitalProductPreview({17      id: selectedVariant?.digital_product.id,18    })19
20    if (downloadUrl.length) {21      window.open(downloadUrl)22    }23  }24    25  // ...26}

This function uses the getDigitalProductPreview function you created earlier to retrieve the preview URL of the selected variant’s digital product.

Finally, in the return statement, add a new button above the add-to-cart button:

src/modules/products/components/product-actions/index.tsx
1return (2  <div>3    {/* Before add to cart */}4    {selectedVariant?.digital_product && (5      <Button6        onClick={handleDownloadPreview}7        variant="secondary"8        className="w-full h-10"9      >10        Download Preview11      </Button>12      )}13  </div>14)

This button is only shown if the selected variant has a digital product. When it’s clicked, the preview URL is retrieved to show the preview media to the customer.

Test Preview Out#

To test it out, run the Next.js starter with the Medusa application, then open the details page of a product that’s digital. You should see a “Download Preview” button to download the preview media of the product.

Add Digital Purchases Page#

You’ll now create the page customers can view their purchased digital product in.

Start by creating the file src/lib/data/digital-products.ts with the following content:

src/lib/data/digital-products.ts
1"use server"2
3import { DigitalProduct } from "../../types/global"4import { getAuthHeaders } from "./cookies"5
6export const getCustomerDigitalProducts = async () => {7  const { digital_products } = await fetch(8    `${process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL}/store/customers/me/digital-products`, {9    credentials: "include",10    headers: {11      ...getAuthHeaders(),12      "x-publishable-api-key": process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY,13    },14  }).then((res) => res.json())15
16  return digital_products as DigitalProduct[]17}

The getCustomerDigitalProducts retrieves the logged-in customer’s purchased digital products by sending a request to the API route you created earlier.

Then, create the file src/modules/account/components/digital-products-list/index.tsx with the following content:

src/modules/account/components/digital-products-list/index.tsx
1"use client"2
3import { Table } from "@medusajs/ui"4import { DigitalProduct } from "../../../../types/global"5import { getDigitalMediaDownloadLink } from "../../../../lib/data/digital-products"6
7type Props = {8  digitalProducts: DigitalProduct[]9}10
11export const DigitalProductsList = ({12  digitalProducts,13}: Props) => {14  return (15    <Table>16      <Table.Header>17        <Table.Row>18          <Table.HeaderCell>Name</Table.HeaderCell>19          <Table.HeaderCell>Action</Table.HeaderCell>20        </Table.Row>21      </Table.Header>22      <Table.Body>23        {digitalProducts.map((digitalProduct) => {24          const medias = digitalProduct.medias?.filter((media) => media.type === "main")25          const showMediaCount = (medias?.length || 0) > 126          return (27            <Table.Row key={digitalProduct.id}>28              <Table.Cell>29                {digitalProduct.name}30              </Table.Cell>31              <Table.Cell>32                <ul>33                  {medias?.map((media, index) => (34                    <li key={media.id}>35                      <a href="#">36                        Download{showMediaCount ? ` ${index + 1}` : ``}37                      </a>38                    </li>39                  ))}40                </ul>41              </Table.Cell>42            </Table.Row>43          )44        })}45      </Table.Body>46    </Table>47  )48}

This adds a DigitalProductsList component that receives a list of digital products and shows them in a table.

Each digital product’s media has a download link. You’ll implement its functionality afterwards.

Next, create the file src/app/[countryCode]/(main)/account/@dashboard/digital-products/page.tsx with the following content:

src/app/[countryCode]/(main)/account/@dashboard/digital-products/page.tsx
1import { Metadata } from "next"2
3import { getCustomerDigitalProducts } from "../../../../../../lib/data/digital-products"4import { DigitalProductsList } from "../../../../../../modules/account/components/digital-products-list"5
6export const metadata: Metadata = {7  title: "Digital Products",8  description: "Overview of your purchased digital products.",9}10
11export default async function DigitalProducts() {12  const digitalProducts = await getCustomerDigitalProducts()13
14  return (15    <div className="w-full" data-testid="orders-page-wrapper">16      <div className="mb-8 flex flex-col gap-y-4">17        <h1 className="text-2xl-semi">Digital Products</h1>18        <p className="text-base-regular">19          View the digital products you've purchased and download them.20        </p>21      </div>22      <div>23        <DigitalProductsList digitalProducts={digitalProducts} />24      </div>25    </div>26  )27}

This adds a new route in your Next.js application to show the customer’s purchased digital products.

In the route, you retrieve the digital’s products using the getCustomerDigitalProducts function and pass them as the prop of the DigitalProductsList component.

Finally, to add a tab in the customer’s account dashboard that links to this page, add it in the src/modules/account/components/account-nav/index.tsx file:

src/modules/account/components/account-nav/index.tsx
1// other imports...2import { Photo } from "@medusajs/icons"3
4const AccountNav = ({5  customer,6}: {7  customer: HttpTypes.StoreCustomer | null8}) => {9  // ...10    11  return (12    <div>13      {/* Add before log out */}14      <li>15        <LocalizedClientLink16          href="/account/digital-products"17          className="flex items-center justify-between py-4 border-b border-gray-200 px-8"18          data-testid="digital-products-link"19        >20          <div className="flex items-center gap-x-2">21            <Photo />22            <span>Digital Products</span>23          </div>24          <ChevronDown className="transform -rotate-90" />25        </LocalizedClientLink>26      </li>27    </div>28  )29}

You add a link to the new route before the log out tab.

Test Purchased Digital Products Page#

To test out this page, first, log-in as a customer and place an order with a digital product.

Then, go to the customer’s account page and click on the new Digital Products tab. You’ll see a table of digital products to download.

To add a download link for the purchased digital products’ medias, first, add a new function to src/lib/data/digital-products.ts:

src/lib/data/digital-products.ts
1export const getDigitalMediaDownloadLink = async (mediaId: string) => {2  const { url } = await fetch(3    `${process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL}/store/customers/me/digital-products/${4      mediaId5    }/download`, {6    credentials: "include",7    method: "POST",8    headers: {9      ...getAuthHeaders(),10      "x-publishable-api-key": process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY,11    },12  }).then((res) => res.json())13
14  return url15}

In this function, you send a request to the download API route you created earlier to retrieve the download URL of a purchased digital product media.

Then, in src/modules/account/components/digital-products-list/index.tsx, add a handleDownload function in the DigitalProductsList component:

src/modules/account/components/digital-products-list/index.tsx
1const handleDownload = async (2  e: React.MouseEvent<HTMLAnchorElement, MouseEvent>,3  mediaId: string4) => {5  e.preventDefault()6
7  const url = await getDigitalMediaDownloadLink(mediaId)8
9  window.open(url)10}

This function uses the getDigitalMediaDownloadLink function to get the download link and opens it in a new window.

Finally, add an onClick handler to the digital product medias’ link in the return statement:

src/modules/account/components/digital-products-list/index.tsx
1<a href="#" onClick={(e) => handleDownload(e, media.id)}>2  Download{showMediaCount ? ` ${index + 1}` : ``}3</a>

Test Download Purchased Digital Product Media#

To test the latest changes out, open the purchased digital products page and click on the Download link of any media in the table. The media’s download link will open in a new page.

NoteThe local file module provider doesn't support private uploads, so the download link won't actually be useful. Instead, use the S3 module provider in production.

Next Steps#

The next steps of this example depend on your use case. This section provides some insight into implementing them.

Storefront Development#

Aside from customizing the Next.js Starter storefront, you can also create a custom storefront. Check out the Storefront Development section to learn how to create a storefront.

Admin Development#

In this recipe, you learned how to customize the admin with UI routes. You can also do further customization using widgets. Learn more in this documentation.

Was this page helpful?
Edit this page