How to build an E2E digital products store using Medusa and Next.js

Rahul / September 29, 2023
17 min read • ––– views
A tutorial teaching you how to build a digital products store using Medusa’s Next.js storefront starter template.
TL;DR
Understand how you use modern tools like Next.js and Medusa to build a webshop selling digital products. For the purpose of this tutorial, we will be focusing on a shop that sells e-books.
Here's a breakdown of what we'll do:
-
To get started, we'll use the Medusa Next.js Starter Template and the Digital Products Recipe.
-
Our next tasks involve improving the product pages to work well with digital products. This means we'll add a button to view a preview of the media content and show important product information.
-
We'll adjust the checkout process to make it smoother and match it with how we deliver digital products,
-
Finally, we'll establish Next.js API routes that will serve to validate product downloads and hide file paths.
Medusa: open-source infrastructure for commerce applications
Medusa builds modularized commerce logic like carts, products, and order management and provide tools to orchestrate them for powerful ecommerce websites, POS applications, commerce-enabled products, and everything in between.
Getting started
Using the Next.js starter, you can create a new Medusa app by running:
npx create-medusa-app@latest --with-nextjs-starter
Next, you can create a user to log in to the admin. You will then need to prepare the backend using the Medusa Digital Products Recipe. After creating the backend, you can create a few example products using your Medusa admin. It is important that they have digital media files for both preview and main files attached to them. Using any key/value pair relevant to your product you should then add in a few product metadata values.
Adding type definitions to the storefront
If you’re using regular JavaScript, you can skip this step.
Before moving on, we should add the needed Typescript type definitions for the digital products of the Next.js storefront.
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
}
Adding media preview
We will proceed to include e-book previews on our product detail page. To achieve this, we'll retrieve the media previews associated with the product variant currently under view. In the file src/lib/data/index.ts
, we will implement a function for fetching product media previews based on the 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]
}
Introduce a preview download option
To provide customers with a glimpse of the e-book's content, we'll offer a preview PDF containing the first few pages. This involves creating a Next API Route to handle file downloads without revealing the file location. Additionally, we'll develop a "download free preview" button component to facilitate this process. If a product variant has preview media, it will be displayed within 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
Revise product and shipping details
Because the product and shipping information for digital products varies from physical ones, we will make adjustments to these sections on the product page.
Product details
I've included relevant product details in the e-book using the product's metadata section in the Medusa admin. Since we don't intend to use the standard attributes, we plan to revamp the ProductInfoTab
component to present any additional metadata we include.
By default, the metadata is organized as an object. To simplify the process of creating our attribute list, we will convert it into an array. In this instance, we'll showcase four attributes from the metadata, with two in each column. If you wish to display a different number of attributes, you can adjust the values in the slice()
function accordingly.
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 Details
Since shipping information doesn't apply to digital products, we'll be modifying the content of this tab. You can make any relevant adjustments to the content within the ShippingInfoTab component in the same file to better suit your store's needs.
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
Streamlining the Checkout
Because we're selling digital products, there's no need to collect our customers' physical addresses. To deliver the e-book, a name and email address are all we need. This allows us to make the checkout process simpler by eliminating any unnecessary input fields. In this example, we'll retain only the first name, last name, country, and email fields, and we'll completely remove the billing address section. Please keep in mind that your specific needs may dictate different input fields.
To begin, we'll modify the checkout types and context by removing any references to values that are no longer necessary.
- 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
Lastly, we'll make changes to the "shipping-details" component to display the pertinent information when the order is successfully completed. In this example, any unnecessary details are eliminated, and we include the buyer's email address for reference.
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
Delivering Digital Products
There are several methods to deliver digital products to customers. We can send a download link via email, include a download button on the order confirmation page, or grant access to purchased items through the user account.
In all these scenarios, our primary aim is to verify if the user attempting to access the product has indeed made a purchase. To achieve this, I've configured the backend to generate a unique token for each digital item within an order. We can use the GET request /store/:token to validate the token and provide the associated file to the user. However, this approach exposes the file URL to the user, which is not ideal for piracy prevention. Therefore, we're planning to establish a Next API route located at src/app/api/download/main/[token]/route.ts
.
This route will handle the token, serving as a proxy to deliver the file directly to the user, ensuring a seamless download experience without revealing 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.
Mission Accomplished!
Congratulations, you've reached the finish line! Don't forget to explore additional Recipes for more ways to make the most of Medusa.