- Get Started
- Product
- Resources
- Tools & SDKs
- Framework
- Reference
- Get Started
- Product
- Resources
- Tools & SDKs
- Framework
- Reference
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:
- Support digital products with multiple media items.
- Manage digital products from the admin dashboard.
- Handle and fulfill digital product orders.
- Allow customers to download their digital product purchases from the storefront.
- All other commerce features that Medusa provides.
Step 1: Install a Medusa Application#
Start by installing the Medusa application on your machine with the following command:
You'll first be asked for the project's name. 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.
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.
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:
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:
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:
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:
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:
Create Main Module Service#
Next, create the main service of the module at src/modules/digital-product/service.ts
with the following content:
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:
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
:
Further Reads#
Step 3: Define Links#
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:
- Link between the
DigitalProduct
model and the Product Module'sProductVariant
model. - Link between the
DigitalProductOrder
model and the Order Module'sOrder
model.
Start by creating the file src/links/digital-product-variant.ts
with the following content:
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:
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#
Step 4: Run Migrations and Sync Links#
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:
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:
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:
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:
Finally, send the following request to retrieve the list of digital products:
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:
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.createDigitalProductStep
: Create the digital product.createDigitalProductMediasStep
: Create the medias associated with the digital product.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:
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:
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:
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:
createProductsWorkflow
as a step to create a Medusa product.createDigitalProductStep
to create the digital product.createDigitalProductMediasStep
to create the digital product’s medias.createRemoteLinkStep
to link the digital product to the product variant.
You’ll test out the workflow in the next section.
Further Reads#
- How to Create a Workflow
- What is the Compensation Function
- Learn more about the remote link function
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:
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:
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:
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.
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:
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:
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:
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:
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:
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:
currentPage
: The number of the current page.pageLimit
: The number of digital products to show per page.count
: The total count of digital products.pagesCount
: A memoized variable that holds the number of pages based oncount
andpageLimit
.canNextPage
: A memoized variable that indicates whether there’s a next page based on whether the current page is less thanpagesCount - 1
.canPreviousPage
: A memoized variable that indicates whether there’s a previous pages based on whether the current page is greater than0
.nextPage
: A function that increments thecurrentPage
.previousPage
: A function that decrements thecurrentPage
.
Then, replace the new TODO fetch digital products
with the following:
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:
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:
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:
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:
And add in the component a changeFiles
function that saves changes related to a media in the medias
state variable:
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:
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:
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.
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:
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:
Then, replace the TODO add create button
in the return statement to show the CreateDigitalProductForm
component:
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.
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:
- Change the status to published.
- Add it to the default sales channel.
- Disable manage inventory of the variant.
- 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:
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
:
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
:
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:
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.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.- If the order has digital products, you:
- create the digital product order.
- 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. - 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. - Emit the
digital_product_order.created
custom event to handle it later in a subscriber and send the customer an email. Medusa provides aemitEventStep
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:
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:
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:
completeCartWorkflow
as a step to create the Medusa order.useQueryGraphStep
to retrieve the order’s items with their associated variants and linked digital products.- Use
when
to check whether the order has digital products. If so:- Use the
createDigitalProductOrderStep
to create the digital product order. - Use the
createRemoteLinkStep
to link the digital product order to the Medusa order. - Use the
createOrderFulfillmentWorkflow
to create a fulfillment for the digital products in the order. - Use the
emitEventStep
to emit a custom event.
- Use the
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:
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:
- Retrieve the digital product order's details. For this, you'll use
useQueryGraphStep
from Medusa's core workflows. - 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:
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:
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:
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:
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
:
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:
- Retrieve the digital product order's details using
useQueryGraphStep
from Medusa's core workflows. - 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:
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:
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:
- Retrieve the preview files of a digital product. This is useful when the customer is browsing the products before purchase.
- List the digital products that the customer has purchased.
- 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:
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:
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:
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:
- Show a preview button on a digital product’s page to view the preview files.
- Add a new tab in the customer’s dashboard to view their purchased digital products.
- 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:
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:
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.
Get Digital Product Preview Links#
To retrieve the links of a digital product’s preview media, add in src/lib/data/products.ts
the following function:
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:
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:
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:
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:
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:
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:
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:
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.
Add Download Link#
To add a download link for the purchased digital products’ medias, first, add a new function to 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:
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:
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.
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.