TL;DR
In this tutorial, you’ll learn how to build a store that sells digital products (like e-books) using Next.js and Medusa.
- We’ll use the Medusa Next.js Starter Template and Digital Products Recipe to get up and running.
- We’ll update the product pages to support digital products
- Add a media preview button
- Display relevant product information
- We’ll simplify the checkout to match the digital products delivery process.
- We’ll create Next.js API routes to validate product downloads and hide file paths.
Medusa: open-source building blocks for commerce
Quick background on Medusa: we create open-source building blocks needed to build amazing ecommerce websites or enable commerce functionality in any product.
Get started
- Create a new Medusa app with the Next.js starter by running:
npx create-medusa-app@latest --with-nextjs-starter
- Create a user and make sure you can log in to the admin.
- Prepare the backend by following our Digital Products Recipe.
- Create one or two example products in your Medusa admin. Make sure they have digital media files (both preview and main files) attached to them. Also add in some product metadata values. You can choose any key/value pairs that might be relevant to you product.
Add in type definitions
ℹ️ If you’re using regular JavaScript, you can skip this
step.
Before going any further, let’s add the required Typescript type definitions for our digital products to our Next.js storefront project.
Code example
// src/types/product-media.ts
import { Product } from "@medusajs/medusa"
import { ProductVariant } from "@medusajs/product"
export enum ProductMediaVariantType {
PREVIEW = "preview",
MAIN = "main",
}
export type ProductMedia = {
id: string
name?: string
file?: string
mime_type?: string
created_at?: Date
updated_at?: Date
attachment_type?: ProductMediaVariantType
variant_id?: string
variants?: ProductMediaVariant[]
}
export type ProductMediaVariant = {
id: string
variant_id: string
product_media_id: string
type: string
created_at: Date
updated_at: Date
}
export type DigitalProduct = Omit<Product, "variants"> & {
product_medias?: ProductMedia[]
variants?: DigitalProductVariant[]
}
export type DigitalProductVariant = ProductVariant & {
product_medias?: ProductMedia
}
Add a digital media preview to the product response
We’re gonna add previews of our e-books to the product detail page. To enable this, we need to fetch the product media preview that belong to the product variant that is currently being viewed. In src/lib/data/index.ts
, we will add a function to get product media previews by variant.
Code example
// src/lib/data/index.ts
// ... other imports
import { DigitalProduct, ProductMedia } from "types/product-media"
// ... rest of the functions
export async function getProductMediaPreviewByVariant(
variant: Variant
): Promise<ProductMedia> {
const { product_medias } = await medusaRequest("GET", `/product-media`, {
query: {
variant_ids: variant.id,
expand: ["variants"],
},
})
.then((res) => res.body)
.catch((err) => {
throw err
})
return product_medias[0]
}
Add a preview download button
To give customers an idea of what the e-book is about, we’re giving away a preview PDF that contains the first few pages. First, we’re gonna create a Next API Route that handles the file downloads without showing the actual file location to the user. After that, we will create a “download free preview” button component that will call the new API Route. If a product variant has preview media available, we will render it inside the product-actions
component.
ℹ️ You can use the newly created
DigitalProduct
and
DigitalProductVariant
types to fix any TypeScript errors > that you may encounter.
Code example: preview download API route
// src/app/api/download/preview/route.ts
import { NextRequest, NextResponse } from "next/server"
export async function GET(req: NextRequest) {
// Get the file info from the URL
const { filepath, filename } = Object.fromEntries(req.nextUrl.searchParams)
// Fetch the PDF file
const pdfResponse = await fetch(filepath)
// Handle the case where the PDF could not be fetched
if (!pdfResponse.ok) return new NextResponse("PDF not found", { status: 404 })
// Get the PDF content as a buffer
const pdfBuffer = await pdfResponse.arrayBuffer()
// Define response headers
const headers = {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${filename}"`, // This sets the file name for the download
}
// Create a NextResponse with the PDF content and headers
const response = new NextResponse(pdfBuffer, {
status: 200,
headers,
})
return response
}
Code example: download button component
// src/modules/products/components/product-media-preview/index.tsx
import Button from "@modules/common/components/button"
import { ProductMedia } from "types/product-media"
type Props = {
media: ProductMedia
}
const ProductMediaPreview: React.FC<Props> = ({ media }) => {
const downloadPreview = () => {
window.location.href = `${process.env.NEXT_PUBLIC_BASE_URL}/api/download/preview?filepath=${media.file}&filename=${media.name}`
}
return (
<div>
<Button variant="secondary" onClick={downloadPreview}>
Download free preview
</Button>
</div>
)
}
export default ProductMediaPreview
Code example: render button in product-actions
// src/modules/products/components/product-actions/index.tsx
// ...other imports
import ProductMediaPreview from "../product-media-preview"
import { getProductMediaPreviewByVariant } from "@lib/data"
const ProductActions: React.FC<ProductActionsProps> = ({ product }) => {
// ...other code
const [productMedia, setProductMedia] = useState({} as ProductMedia)
useEffect(() => {
const getProductMedia = async () => {
if (!variant) return
await getProductMediaPreviewByVariant(variant).then((res) => {
setProductMedia(res)
})
}
getProductMedia()
}, [variant])
return (
// ...other code
{productMedia && <ProductMediaPreview media={productMedia} />}
<Button onClick={addToCart}>
{!inStock ? "Out of stock" : "Add to cart"}
</Button>
</div>
)
}
export default ProductActions
Update product and shipping information
As the product and shipping information for digital product differs from physical products, we’re gonna update these sections on the product page.
Product information
I’ve added relevant product attributes to the e-book using the product’s metadata section in the Medusa admin. As we don’t want to use the standard attributes, we’re gonna refactor the ProductInfoTab
component to display any metadata we add.
In the default response, metadata is structured as an object. We’re gonna map it to an array so we can easily loop through the key/value pairs to build our attribute list. In this example we’ll display 4 attributes from metadata, 2 in each column. Edit the values in the slice()
if you want to display more or less attributes.
Code example
// src/modules/products/components/product-tabs/index.tsx
// ... other components
const ProductInfoTab = ({ product }: ProductTabsProps) => {
// map the metadata object to an array
const metadata = useMemo(() => {
if (!product.metadata) return []
return Object.keys(product.metadata).map((key) => {
return [key, product.metadata?.[key]]
})
}, [product])
return (
<Tab.Panel className="text-small-regular py-8">
<div className="grid grid-cols-2 gap-x-8">
<div className="flex flex-col gap-y-4">
{/* Map the metadata as product information */}
{metadata &&
metadata.slice(0, 2).map(([key, value], i) => (
<div key={i}>
<span className="font-semibold">{key}</span>
<p>{value}</p>
</div>
))}
</div>
<div className="flex flex-col gap-y-4">
{metadata.length > 2 &&
metadata.slice(2, 4).map(([key, value], i) => {
return (
<div key={i}>
<span className="font-semibold">{key}</span>
<p>{value}</p>
</div>
)
})}
</div>
</div>
{product.tags?.length ? (
<div>
<span className="font-semibold">Tags</span>
</div>
) : null}
</Tab.Panel>
)
}
// ... other components
Shipping information
As the shipping information is not relevant for digital products, we’re gonna change the contents of the tab. This is handled in the ShippingInfoTab
component within the same file. You can write anything that’s relevant for your store here.
Code example
// src/modules/products/components/product-tabs/index.tsx
// ... other components
const ProductTabs = ({ product }: ProductTabsProps) => {
const tabs = useMemo(() => {
return [
{
label: "Product Information",
component: <ProductInfoTab product={product} />,
},
{
label: "E-book delivery",
component: <ShippingInfoTab />,
},
]
}, [product])
// ... rest of code
}
// ... other components
const ShippingInfoTab = () => {
return (
<Tab.Panel className="text-small-regular py-8">
<div className="grid grid-cols-1 gap-y-8">
<div className="flex items-start gap-x-2">
<FastDelivery />
<div>
<span className="font-semibold">Instant delivery</span>
<p className="max-w-sm">
Your e-book will be delivered instantly via email. You can also
download it from your account anytime.
</p>
</div>
</div>
<div className="flex items-start gap-x-2">
<Refresh />
<div>
<span className="font-semibold">Free previews</span>
<p className="max-w-sm">
Get a free preview of the e-book before you buy it. Just click the
button above to download it.
</p>
</div>
</div>
</div>
</Tab.Panel>
)
}
// ... other components
Update checkout
Since we sell digital products, we don’t need the physical address of our customers. A name and email will suffice for delivering the e-book. This means we can simplify the checkout process by removing unnecessary input fields. In this example we’ll just keep first name, last name, country and email. We’ll completely get rid of the billing address. Note that your use case may require different input fields.
First, we’ll update the checkout types and context by removing any references to values we no longer need.
Code example - You can copy/paste this into src/lib/context/checkout-context.tsx
// src/lib/context/checkout-context.tsx
"use client"
import { medusaClient } from "@lib/config"
import useToggleState, { StateType } from "@lib/hooks/use-toggle-state"
import { Cart, Customer, StorePostCartsCartReq } from "@medusajs/medusa"
import Wrapper from "@modules/checkout/components/payment-wrapper"
import { isEqual } from "lodash"
import {
formatAmount,
useCart,
useCartShippingOptions,
useMeCustomer,
useRegions,
useSetPaymentSession,
useUpdateCart,
} from "medusa-react"
import { useRouter } from "next/navigation"
import React, { createContext, useContext, useEffect, useMemo } from "react"
import { FormProvider, useForm, useFormContext } from "react-hook-form"
import { useStore } from "./store-context"
type AddressValues = {
first_name: string
last_name: string
country_code: string
}
export type CheckoutFormValues = {
shipping_address: AddressValues
billing_address?: AddressValues
email: string
}
interface CheckoutContext {
cart?: Omit<Cart, "refundable_amount" | "refunded_total">
shippingMethods: { label?: string; value?: string; price: string }[]
isLoading: boolean
readyToComplete: boolean
sameAsBilling: StateType
editAddresses: StateType
initPayment: () => Promise<void>
setAddresses: (addresses: CheckoutFormValues) => void
setSavedAddress: (address: AddressValues) => void
setShippingOption: (soId: string) => void
setPaymentSession: (providerId: string) => void
onPaymentCompleted: () => void
}
const CheckoutContext = createContext<CheckoutContext | null>(null)
interface CheckoutProviderProps {
children?: React.ReactNode
}
const IDEMPOTENCY_KEY = "create_payment_session_key"
export const CheckoutProvider = ({ children }: CheckoutProviderProps) => {
const {
cart,
setCart,
addShippingMethod: {
mutate: setShippingMethod,
isLoading: addingShippingMethod,
},
completeCheckout: { mutate: complete, isLoading: completingCheckout },
} = useCart()
const { customer } = useMeCustomer()
const { countryCode } = useStore()
const methods = useForm<CheckoutFormValues>({
defaultValues: mapFormValues(customer, cart, countryCode),
reValidateMode: "onChange",
})
const {
mutate: setPaymentSessionMutation,
isLoading: settingPaymentSession,
} = useSetPaymentSession(cart?.id!)
const { mutate: updateCart, isLoading: updatingCart } = useUpdateCart(
cart?.id!
)
const { shipping_options } = useCartShippingOptions(cart?.id!, {
enabled: !!cart?.id,
})
const { regions } = useRegions()
const { resetCart, setRegion } = useStore()
const { push } = useRouter()
const editAddresses = useToggleState()
const sameAsBilling = useToggleState(
cart?.billing_address && cart?.shipping_address
? isEqual(cart.billing_address, cart.shipping_address)
: true
)
/**
* Boolean that indicates if a part of the checkout is loading.
*/
const isLoading = useMemo(() => {
return (
addingShippingMethod ||
settingPaymentSession ||
updatingCart ||
completingCheckout
)
}, [
addingShippingMethod,
completingCheckout,
settingPaymentSession,
updatingCart,
])
/**
* Boolean that indicates if the checkout is ready to be completed. A checkout is ready to be completed if
* the user has supplied a email, shipping address, billing address, shipping method, and a method of payment.
*/
const readyToComplete = useMemo(() => {
return (
!!cart &&
!!cart.email &&
!!cart.shipping_address &&
!!cart.billing_address &&
!!cart.payment_session &&
cart.shipping_methods?.length > 0
)
}, [cart])
const shippingMethods = useMemo(() => {
if (shipping_options && cart?.region) {
return shipping_options?.map((option) => ({
value: option.id,
label: option.name,
price: formatAmount({
amount: option.amount || 0,
region: cart.region,
}),
}))
}
return []
}, [shipping_options, cart])
/**
* Resets the form when the cart changed.
*/
useEffect(() => {
if (cart?.id) {
methods.reset(mapFormValues(customer, cart, countryCode))
}
}, [customer, cart, methods, countryCode])
useEffect(() => {
if (!cart) {
editAddresses.open()
return
}
if (cart?.shipping_address && cart?.billing_address) {
editAddresses.close()
return
}
editAddresses.open()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cart])
/**
* Method to set the selected shipping method for the cart. This is called when the user selects a shipping method, such as UPS, FedEx, etc.
*/
const setShippingOption = (soId: string) => {
if (cart) {
setShippingMethod(
{ option_id: soId },
{
onSuccess: ({ cart }) => setCart(cart),
}
)
}
}
/**
* Method to create the payment sessions available for the cart. Uses a idempotency key to prevent duplicate requests.
*/
const createPaymentSession = async (cartId: string) => {
return medusaClient.carts
.createPaymentSessions(cartId, {
"Idempotency-Key": IDEMPOTENCY_KEY,
})
.then(({ cart }) => cart)
.catch(() => null)
}
/**
* Method that calls the createPaymentSession method and updates the cart with the payment session.
*/
const initPayment = async () => {
if (cart?.id && !cart.payment_sessions?.length && cart?.items?.length) {
const paymentSession = await createPaymentSession(cart.id)
if (!paymentSession) {
setTimeout(initPayment, 500)
} else {
setCart(paymentSession)
return
}
}
}
/**
* Method to set the selected payment session for the cart. This is called when the user selects a payment provider, such as Stripe, PayPal, etc.
*/
const setPaymentSession = (providerId: string) => {
if (cart) {
setPaymentSessionMutation(
{
provider_id: providerId,
},
{
onSuccess: ({ cart }) => {
setCart(cart)
},
}
)
}
}
const prepareFinalSteps = () => {
initPayment()
if (shippingMethods?.length && shippingMethods?.[0]?.value) {
setShippingOption(shippingMethods[0].value)
}
}
const setSavedAddress = (address: AddressValues) => {
const setValue = methods.setValue
setValue("shipping_address", {
country_code: address.country_code || "",
first_name: address.first_name || "",
last_name: address.last_name || "",
})
}
/**
* Method that validates if the cart's region matches the shipping address's region. If not, it will update the cart region.
*/
const validateRegion = (countryCode: string) => {
if (regions && cart) {
const region = regions.find((r) =>
r.countries.map((c) => c.iso_2).includes(countryCode)
)
if (region && region.id !== cart.region.id) {
setRegion(region.id, countryCode)
}
}
}
/**
* Method that sets the addresses and email on the cart.
*/
const setAddresses = (data: CheckoutFormValues) => {
const { shipping_address, billing_address, email } = data
const payload: StorePostCartsCartReq = {
shipping_address,
email,
}
if (isEqual(shipping_address, billing_address)) {
sameAsBilling.open()
}
if (sameAsBilling.state) {
payload.billing_address = shipping_address
} else {
payload.billing_address = billing_address
}
updateCart(payload, {
onSuccess: ({ cart }) => {
setCart(cart)
prepareFinalSteps()
},
})
}
/**
* Method to complete the checkout process. This is called when the user clicks the "Complete Checkout" button.
*/
const onPaymentCompleted = () => {
complete(undefined, {
onSuccess: ({ data }) => {
resetCart()
push(`/order/confirmed/${data.id}`)
},
})
}
return (
<FormProvider {...methods}>
<CheckoutContext.Provider
value={{
cart,
shippingMethods,
isLoading,
readyToComplete,
sameAsBilling,
editAddresses,
initPayment,
setAddresses,
setSavedAddress,
setShippingOption,
setPaymentSession,
onPaymentCompleted,
}}
>
<Wrapper paymentSession={cart?.payment_session}>{children}</Wrapper>
</CheckoutContext.Provider>
</FormProvider>
)
}
export const useCheckout = () => {
const context = useContext(CheckoutContext)
const form = useFormContext<CheckoutFormValues>()
if (context === null) {
throw new Error(
"useProductActionContext must be used within a ProductActionProvider"
)
}
return { ...context, ...form }
}
/**
* Method to map the fields of a potential customer and the cart to the checkout form values. Information is assigned with the following priority:
* 1. Cart information
* 2. Customer information
* 3. Default values - null
*/
const mapFormValues = (
customer?: Omit<Customer, "password_hash">,
cart?: Omit<Cart, "refundable_amount" | "refunded_total">,
currentCountry?: string
): CheckoutFormValues => {
const customerShippingAddress = customer?.shipping_addresses?.[0]
const customerBillingAddress = customer?.billing_address
return {
shipping_address: {
first_name:
cart?.shipping_address?.first_name ||
customerShippingAddress?.first_name ||
"",
last_name:
cart?.shipping_address?.last_name ||
customerShippingAddress?.last_name ||
"",
country_code:
currentCountry ||
cart?.shipping_address?.country_code ||
customerShippingAddress?.country_code ||
"",
},
billing_address: {
first_name:
cart?.billing_address?.first_name ||
customerBillingAddress?.first_name ||
"",
last_name:
cart?.billing_address?.last_name ||
customerBillingAddress?.last_name ||
"",
country_code:
cart?.shipping_address?.country_code ||
customerBillingAddress?.country_code ||
"",
},
email: cart?.email || customer?.email || "",
}
}
Now that the context is updated, we’ll remove the redundant input fields from the checkout form.
Code example
// src/modules/checkout/components/addresses/index.tsx
import { useCheckout } from "@lib/context/checkout-context"
import Button from "@modules/common/components/button"
import Spinner from "@modules/common/icons/spinner"
import ShippingAddress from "../shipping-address"
const Addresses = () => {
const {
editAddresses: { state: isEdit, toggle: setEdit },
setAddresses,
handleSubmit,
cart,
} = useCheckout()
return (
<div className="bg-white">
<div className="text-xl-semi flex items-center gap-x-4 px-8 pb-6 pt-8">
<div className="bg-gray-900 w-8 h-8 rounded-full text-white flex justify-center items-center text-sm">
1
</div>
<h2>Shipping address</h2>
</div>
{isEdit ? (
<div className="px-8 pb-8">
<ShippingAddress />
<Button
className="max-w-[200px] mt-6"
onClick={handleSubmit(setAddresses)}
>
Continue to delivery
</Button>
</div>
) : (
<div>
<div className="bg-gray-50 px-8 py-6 text-small-regular">
{cart && cart.shipping_address ? (
<div className="flex items-start gap-x-8">
<div className="bg-green-400 rounded-full min-w-[24px] h-6 flex items-center justify-center text-white text-small-regular">
✓
</div>
<div className="flex items-start justify-between w-full">
<div className="flex flex-col">
<span>
{cart.shipping_address.first_name}{" "}
{cart.shipping_address.last_name}
{cart.shipping_address.country}
</span>
<div className="mt-4 flex flex-col">
<span>{cart.email}</span>
</div>
</div>
<div>
<button onClick={setEdit}>Edit</button>
</div>
</div>
</div>
) : (
<div className="">
<Spinner />
</div>
)}
</div>
</div>
)}
</div>
)
}
export default Addresses
And finally, we’ll update the shipping-details
component to show relevant values on order completion. In the example all redundant values are removed, and the email address of the buyer is added in.
Code example
// src/modules/order/components/shipping-details/index.tsx
import { Address, ShippingMethod } from "@medusajs/medusa"
type ShippingDetailsProps = {
address: Address
shippingMethods: ShippingMethod[]
email: string
}
const ShippingDetails = ({
address,
shippingMethods,
email,
}: ShippingDetailsProps) => {
return (
<div className="text-base-regular">
<h2 className="text-base-semi">Delivery</h2>
<div className="my-2">
<h3 className="text-small-regular text-gray-700">Details</h3>
<div className="flex flex-col">
<span>{`${address.first_name} ${address.last_name}`}</span>
<span>{email}</span>
</div>
</div>
<div className="my-2">
<h3 className="text-small-regular text-gray-700">Delivery method</h3>
<div>
{shippingMethods.map((sm) => {
return <div key={sm.id}>{sm.shipping_option.name}</div>
})}
</div>
</div>
</div>
)
}
export default ShippingDetails
Digital product delivery
There’s multiple ways to deliver digital products to a customer. We can deliver a download link via email, add a download button to the order confirmation page or give access to purchased items in the user account.
In all cases, we want to validate if the user that tries to access the product has actually purchased it. To do this, I’ve set up the backend to generate a unique token for every digital line item in an order. We can GET /store/:token
to validate the token and return the associated file to the user. However, this will reveal the file url to the user, and we don’t want that for piracy reasons. We’re gonna set up a Next API route in src/app/api/download/main/[token]/route.ts
to pass the token and proxy the file to the user, so they’ll get a direct download without seeing the exact file location.
Code example
// src/app/api/download/main/[token]/route.ts
import { NextRequest, NextResponse } from "next/server"
export async function GET(
req: NextRequest,
{ params }: { params: Record<string, any> }
) {
// Get the token from the URL
const { token } = params
// Define the URL to fetch the PDF file data from
const pdfUrl = `${process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL}/store/product-media/${token}`
// Fetch the PDF file data
const { file, filename } = await fetch(pdfUrl).then((res) => res.json())
// Handle the case where the token is invalid
if (!file) return new NextResponse("Invalid token", { status: 401 })
// Fetch the PDF file
const pdfResponse = await fetch(file)
// Handle the case where the PDF could not be fetched
if (!pdfResponse.ok) return new NextResponse("PDF not found", { status: 404 })
// Get the PDF content as a buffer
const pdfBuffer = await pdfResponse.arrayBuffer()
// Define response headers
const headers = {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${filename}"`, // This sets the file name for the download
}
// Create a NextResponse with the PDF content and headers
const response = new NextResponse(pdfBuffer, {
status: 200,
headers,
})
return response
}
We can now link to this API route from the delivery email like: {your_store_url}/api/download/main/{token}
.
You can add your own logic to invalidate tokens after a certain time or X number of downloads.
Done!
You've made it to the end! If you have any questions or comments about this guide, let me know.
Check out our other Recipes for more Medusa use cases.
Top comments (1)
Hello! Do you have a repository for this project?