- Get Started
- Product
- Resources
- Tools & SDKs
- Framework
- Reference
- Get Started
- Product
- Resources
- Tools & SDKs
- Framework
- Reference
Integrate Medusa with Sanity
In this guide, you'll learn how to integrate Medusa with Sanity.
When you install a Medusa application, you get a fully-fledged commerce platform with support for customizations. While Medusa allows you to manage basic content, such as product description and images, you might need rich content-management features, such as localized content. Medusa's framework supports you in integrating a CMS with these features.
Sanity is a CMS that simplifies managing content from third-party sources into a single interface. By integrating it with Medusa, you can manage your storefront and commerce-related content, such as product details, from a single interface. You also benefit from advanced content-management features, such as live-preview editing.
This guide will teach you how to:
- Install and set up Medusa.
- Install and set up Sanity with Medusa's Next.js Starter storefront.
- Sync product data from Medusa to Sanity when a product is created or updated.
- Customize the Medusa Admin dashboard to check the sync status and trigger syncing products to Sanity.
You can follow this guide whether you're new to Medusa or an advanced Medusa developer. This guide also assumes you're familiar with Sanity concepts, which you can learn about in their documentation.
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. Then, when you're asked whether you want to install the Next.js storefront, choose Y
for yes.
Afterwards, the installation process will start, which will install the Medusa application in a directory with your project's name, and the Next.js storefront in a 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. The Next.js storefront is also running at http://localhost:8000
.
Step 2: Install Sanity Client SDK#
In this step, you'll install Sanity's JavaScript client SDK in the Medusa application, which you'll use later in your code when sending requests to Sanity.
In your terminal, move to the Medusa application's directory and run the following command:
Step 3: Create a Sanity Project#
When the Medusa application connects to Sanity, it must connect to a project in Sanity.
So, before building the integration in Medusa, create a project in Sanity using their website:
- Sign in or sign up on the Sanity website.
- On your account's dashboard, click the "Create new project" button.
- Enter a project name and click "Create Project"
You'll go back to the project's setting page in a later step.
Step 4: Create Sanity Module#
To integrate third-party services into Medusa, you create a custom module. A module is a re-usable package with functionalities related to a single feature or domain. Medusa integrates the module into your application without implications or side effects on your setup.
In this step, you'll create a Sanity Module that provides the interface to connect to and interact with Sanity. In later steps, you'll use the functionalities provided by this module to sync products to Sanity or retrieve documents from it.
Create Module Directory#
A module is created under the src/modules
directory of your Medusa application. So, create the directory src/modules/sanity
.
Create Service#
You define a module's functionalities in a service. A service is a TypeScript or JavaScript class that the module exports. In the service's methods, you can connect to the database, which is useful if your module defines tables in the database, or connect to a third-party service.
Medusa registers the module's service in the Medusa container, allowing you to easily resolve the service from other customizations and use its methods.
In this section, you'll create the Sanity Module's service and the methods necessary to connect to Sanity.
Start by creating the file src/modules/sanity/service.ts
with the following content:
You create the SanityModuleService
class that for now only has three properties:
client
property of typeSanityClient
(from the Sanity SDK you installed in the previous step) to send requests to Sanity.studioUrl
property which will hold the URL to access the Sanity studio.logger
property, which is an instance of Medusa's Logger, to log messages.
In the service, you want to initialize the client early-on so that you can use it in the service's methods. This requires options to be passed to the client, like the Sanity API key or project ID.
So, add after the import at the top of the file the following types:
1// other imports...2 3const SyncDocumentTypes = {4 PRODUCT: "product",5} as const6 7type SyncDocumentTypes =8 (typeof SyncDocumentTypes)[keyof typeof SyncDocumentTypes];9 10type ModuleOptions = {11 api_token: string;12 project_id: string;13 api_version: string;14 dataset: "production" | "development";15 type_map?: Record<SyncDocumentTypes, string>;16 studio_url?: string;17}
The ModuleOptions
type defines the type of options that the module expects:
api_token
: API token to connect to Sanity.project_id
: The ID of the Sanity project.api_version
: The Sanity API version.dataset
: The dataset to use, which is eitherproduction
ordevelopment
.type_map
: The types to sync from Medusa to Sanity. For simplicity, this guide only covers syncing products, but you can support other data types like product categories, too.studio_url
: The URL to the Sanity studio. This is used to show the studio URL later in the Medusa Admin dashboard.
You can now initialize the client, which you'll do in the constructor
of the SanityModuleService
:
1import {2 // other imports...3 createClient,4} from "@sanity/client"5 6// types...7 8type InjectedDependencies = {9 logger: Logger10};11 12class SanityModuleService {13 // properties...14 constructor({15 logger,16 }: InjectedDependencies, options: ModuleOptions) {17 this.client = createClient({18 projectId: options.project_id,19 apiVersion: options.api_version,20 dataset: options.dataset,21 token: options.api_token,22 })23 this.logger = logger24 25 this.logger.info("Connected to Sanity")26 27 this.studioUrl = options.studio_url28 29 // TODO initialize more properties30 }31}
The service's constructor accepts two parameters:
- Resources to resolve from the Module's container. A module has a different container than the Medusa application, which you can learn more about it in this documentation.
- The options passed to the module.
In the constructor, you create a Sanity client using the createClient
function imported from @sanity/client
. You pass it the options that the module receives.
You also initialize the logger
and studioUrl
properties, and log a message indicating that connection to Sanity was successful.
Transform Product Data
When you create or update products in Sanity, you must prepare the product object based on what Sanity expects.
So, you'll add methods to the service that transform a Medusa product to a Sanity document object.
Start by adding the following types and class properties to src/modules/sanity/service.ts
:
1type SyncDocumentInputs<T> = T extends "product"2 ? ProductDTO3 : never4 5type TransformationMap<T> = Record<6 SyncDocumentTypes,7 (data: SyncDocumentInputs<T>) => any8>;9 10class SanityModuleService {11 // other properties...12 private typeMap: Record<SyncDocumentTypes, string>13 private createTransformationMap: TransformationMap<SyncDocumentTypes>14 private updateTransformationMap: TransformationMap<SyncDocumentTypes>15 16 // ...17}
First, you define types for a transformation map, which is a map that pairs up a document type (such as product
) to a function that handles transforming its data.
Then, in the service, you define three new properties:
typeMap
: Pair ofSyncDocumentTypes
values (for example,product
) and their type name in Sanity.createTransformationMap
: Pair ofSyncDocumentTypes
values (for example,product
) and the method used to transform a Medusa product to a Sanity document data to be created.updateTransformationMap
: Pair ofSyncDocumentTypes
values (for example,product
) and the method used to transform a Medusa product to a Sanity update operation.
Next, add the following two methods to transform a product:
1// other imports...2import {3 ProductDTO,4} from "@medusajs/framework/types"5 6class SanityModuleService {7 // ...8 private transformProductForCreate = (product: ProductDTO) => {9 return {10 _type: this.typeMap[SyncDocumentTypes.PRODUCT],11 _id: product.id,12 title: product.title,13 specs: [14 {15 _key: product.id,16 _type: "spec",17 title: product.title,18 lang: "en",19 },20 ],21 }22 }23 24 private transformProductForUpdate = (product: ProductDTO) => {25 return {26 set: {27 title: product.title,28 },29 }30 }31}
The transformProductForCreate
method accepts a product and returns an object that you'll later pass to Sanity to create the product document. Similarly, the transformProductForUpdate
method accepts a product and returns an object that you'll later pass to Sanity to update the product document.
Finally, initialize the new properties you added in the SanityModuleService
's constructor:
1class SanityModuleService {2 // ...3 constructor({4 logger,5 }: InjectedDependencies, options: ModuleOptions) {6 // ...7 this.typeMap = Object.assign(8 {},9 {10 [SyncDocumentTypes.PRODUCT]: "product",11 },12 options.type_map || {}13 )14 15 this.createTransformationMap = {16 [SyncDocumentTypes.PRODUCT]: this.transformProductForCreate,17 }18 19 this.updateTransformationMap = {20 [SyncDocumentTypes.PRODUCT]: this.transformProductForUpdate,21 }22 }23 // ...24}
You initialize the typeMap
property to map the product
type in Medusa to the product
schema type in Sanity. You also initialize the createTransformationMap
and updateTransformationMap
to map the methods to transform a product for creation or update.
Methods to Manage Documents
In this section, you'll add the methods that accept data from Medusa and create or update them as documents in Sanity.
Add the following methods to the SanityModuleService
class:
1// other imports...2import {3 // ...4 FirstDocumentMutationOptions,5} from "@sanity/client"6 7class SanityModuleService {8 // ...9 async upsertSyncDocument<T extends SyncDocumentTypes>(10 type: T,11 data: SyncDocumentInputs<T>12 ) {13 const existing = await this.client.getDocument(data.id)14 if (existing) {15 return await this.updateSyncDocument(type, data)16 }17 18 return await this.createSyncDocument(type, data)19 }20 21 async createSyncDocument<T extends SyncDocumentTypes>(22 type: T,23 data: SyncDocumentInputs<T>,24 options?: FirstDocumentMutationOptions25 ) {26 const doc = this.createTransformationMap[type](data)27 return await this.client.create(doc, options)28 }29 30 async updateSyncDocument<T extends SyncDocumentTypes>(31 type: T,32 data: SyncDocumentInputs<T>33 ) {34 const operations = this.updateTransformationMap[type](data)35 return await this.client.patch(data.id, operations).commit()36 }37}
You add three methods:
upsertSyncDocument
: Creates or updates a document in Sanity for a data type in Medusa.createSyncDocument
: Creates a document in Sanity for a data type in Medusa. It uses thecreateTransformationMap
property to use the transform method of the specified Medusa data type (for example, a product's data).updateSyncDocument
: Updates a document in Sanity for a data type in Medusa. It uses theupdateTransformationMap
property to use the transform method of the specified Medusa data type (for example, a product's data).
You also need methods to manage the Sanity documents without transformations. So, add the following methods to SanityModuleService
:
1class SanityModuleService {2 // ...3 async retrieve(id: string) {4 return this.client.getDocument(id)5 }6 7 async delete(id: string) {8 return this.client.delete(id)9 }10 11 async update(id: string, data: any) {12 return await this.client.patch(id, {13 set: data,14 }).commit()15 }16 17 async list(18 filter: {19 id: string | string[]20 }21 ) {22 const data = await this.client.getDocuments(23 Array.isArray(filter.id) ? filter.id : [filter.id]24 )25 26 return data.map((doc) => ({27 id: doc?._id,28 ...doc,29 }))30 }31}
You add other three methods:
retrieve
to retrieve a document by its ID.delete
to delete a document by its ID.update
to update a document by its ID with new data.list
to list documents, with ability to filter them by their IDs. Since a Sanity document's ID is a product's ID, you can pass product IDs as a filter to retrieve their documents.
Export Module Definition#
The SanityModuleService
class now has the methods necessary to connect to and perform actions in Sanity.
Next, you must export the Module definition, which lets Medusa know what the Module's name is and what is its service.
Create the file src/modules/sanity/index.ts
with the following content:
In the file, you export the SANITY_MODULE
which is the Module's name. You'll use it later when you resolve the module from the Medusa container.
You also export the module definition using the Module
utility function, which accepts as a first parameter the module's name, and as a second parameter an object having a service
property, indicating the module's service.
Add Module to Configurations#
Finally, to register a module in Medusa, you must add it to Medusa's configurations.
Medusa's configurations are set in the medusa-config.ts
file, which is at the root directory of your Medusa application. The configuration object accepts a modules
array, whose value is an array of modules to add to the application.
Add the modules
property to the exported configurations in medusa-config.ts
:
1// ...2 3module.exports = defineConfig({4 // ...5 modules: [6 {7 resolve: "./src/modules/sanity",8 options: {9 api_token: process.env.SANITY_API_TOKEN,10 project_id: process.env.SANITY_PROJECT_ID,11 api_version: new Date().toISOString().split("T")[0],12 dataset: "production",13 studio_url: process.env.SANITY_STUDIO_URL || 14 "http://localhost:3000/studio",15 type_map: {16 product: "product",17 },18 },19 },20 ],21})
In the modules
array, you pass a module object having the following properties:
resolve
: The path to the module to register in the application. It can also be the name of an NPM package.options
: An object of options to pass to the module. These are the options you expect and use in the module's service.
Some of the module's options, such as the Sanity API key, are set in environment variables. So, add the following environment variables to .env
:
Where:
SANITY_API_TOKEN
: The API key token to connect to Sanity, which you can retrieve from the Sanity project's dashboard:- Go to the API tab.
- Scroll down to Tokens and click on the "Add API Token" button.
- Enter a name for the API token, choose "Editor" for the permissions, then click Save.
SANITY_PROJECT_ID
: The ID of the project, which you can find at the top section of your Sanity project's dashboard.
SANITY_STUDIO_URL
: The URL to access the studio. You'll set the studio up in a later section, but for now set it tohttp://localhost:8000/studio
.
Test the Module#
To test that the module is working, you'll start the Medusa application and see if the "Connected to Sanity" message is logged in the console.
To start the Medusa application, run the following command in the application's directory:
If you see the following message among the logs:
That means your Sanity credentials were correct, and Medusa was able to connect to Sanity.
In the next steps, you'll create a link between the Product and Sanity modules to retrieve data between them easily, and build a flow around the Sanity Module to sync data.
Step 5: Link Product and Sanity Modules#
Since a product has a document in Sanity, you want to build an association between the Product and Sanity modules so that when you retrieve a product, you also retrieve its associated Sanity document.
However, modules are isolated to ensure they're re-usable and don't have side effects when integrated into the Medusa application. So, to build associations between modules, you define module links.
A Module Link associates two modules' data models while maintaining module isolation. A data model can be a table in the database or a virtual model from an external systems.
In this section, you'll define a link between the Product and Sanity modules.
Links are defined in a TypeScript or JavaScript file under the src/links
directory. So, create the file src/links/product-sanity.ts
with the following content:
1import { defineLink } from "@medusajs/framework/utils"2import ProductModule from "@medusajs/medusa/product"3import { SANITY_MODULE } from "../modules/sanity"4 5defineLink(6 {7 ...ProductModule.linkable.product.id,8 field: "id",9 },10 {11 linkable: {12 serviceName: SANITY_MODULE,13 alias: "sanity_product",14 primaryKey: "id",15 },16 },17 {18 readOnly: true,19 }20)
You define a link using the defineLink
utility. It accepts three parameters:
- The first data model part of the link. In this case, it's the Product Module's
product
data model. A module has a speciallinkable
property that contain link configurations for its data models. - The second data model part of the link. Since the Sanity Module doesn't have a Medusa data model, you specify the configurations in a
linkable
object that has the following properties:serviceName
: The registration name in the Medusa container of the service managing the data model, which in this case is the Sanity Module's name (since the module's service is registered under that name).alias
: The name to refer to the model part of this link, such as when retrieving the Sanity document of a product. You'll use this in a later section.primaryKey
: The name of the data model's primary key field.
- An object of configurations for the module link. By default, Medusa creates a table in the database to represent the link you define. Since the module link isn't created between two Medusa data models, you enable the
readOnly
configuration, which will tell Medusa not to create a table in the database for this link.
In the next steps, you'll see how this link allows you to retrieve documents when retrieving products.
Step 6: Sync Data to Sanity#
After integrating Sanity with a custom module, you now want to sync product data from Medusa to Sanity, automatically and manually. To implement the sync logic, you need a workflow.
A workflow is a series of queries and actions, called steps, that complete a task. You construct a workflow like you construct a function, but it's a special function that allows you to track its executions' progress, define roll-back logic, and configure other advanced features. You'll see how all of this works in the upcoming sections.
Within a workflow's steps, you resolve modules to use their service's functionalities as part of a bigger flow. Then, you can execute the workflow from other customizations, such as in response to an event or in an API route.
In this section, you'll create a workflow that syncs products from Medusa to Sanity. Later, you'll execute this workflow when a product is created or updated, or when an admin user triggers the syncing manually.
Create Step#
The syncing workflow will have a single step that syncs products provided as an input to Sanity.
So, to implement that step, create the file src/workflows/sanity-sync-products/steps/sync.ts
with the following content:
1import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"2import { ProductDTO } from "@medusajs/framework/types"3import { 4 ContainerRegistrationKeys,5} from "@medusajs/framework/utils"6import SanityModuleService from "../../../modules/sanity/service"7import { SANITY_MODULE } from "../../../modules/sanity"8 9export type SyncStepInput = {10 product_ids?: string[];11}12 13export const syncStep = createStep(14 { name: "sync-step", async: true },15 async (input: SyncStepInput, { container }) => {16 const sanityModule: SanityModuleService = container.resolve(SANITY_MODULE)17 const query = container.resolve(ContainerRegistrationKeys.QUERY)18 19 const total = 020 const upsertMap: {21 before: any22 after: any23 }[] = []24 25 const batchSize = 20026 const hasMore = true27 const offset = 028 const filters = {29 id: input.product_ids || [],30 }31 32 while (hasMore) {33 const {34 data: products,35 metadata: { count },36 } = await query.graph({37 entity: "product",38 fields: [39 "id",40 "title",41 // @ts-ignore42 "sanity_product.*",43 ],44 // @ts-ignore45 filters,46 pagination: {47 skip: offset,48 take: batchSize,49 order: {50 id: "ASC",51 },52 },53 })54 55 // TODO sync products56 }57 }58)
You define the syncStep
using the createStep
function, which accepts two parameters:
- An object of step configurations. The object must have the
name
property, which is this step's unique name. Enabling theasync
property means that the workflow should run asynchronously in the background. This is useful when the workflow is triggered manually through an HTTP request, meaning the response will be returned to the client even if the workflow hasn't finished executing. - The step's function definition as a second parameter.
The step function accepts the step's input as a first parameter, and an object of options as a second. The object of options has a container
property, which is an instance of the Medusa container that you can use to resolve resources.
In the step, you resolve from the Medusa container Sanity Module's service and Query, which is a tool that allows you to retrieve data across modules and links.
You use Query's graph
method to retrieve products, filtering them by their IDs and applying pagination configurations. The graph
method accepts a fields
property in its object parameter, which indicates the product data model's fields and relations to retrieve.
Notice that you pass sanity_product.*
in the fields
array. Medusa will retrieve the Sanity document of each product using Sanity Module's list
method and attach it to the returned product. So, you don't have to retrieve the products and documents separately. Each product object in the returned array will look similar to this:
Next, you want to sync the retrieved products. So, replace the TODO
with the following:
1// other imports...2import { 3 // ...4 promiseAll,5} from "@medusajs/framework/utils"6 7export const syncStep = createStep(8 { name: "sync-step", async: true },9 async (input: SyncStepInput, { container }) => {10 // ...11 12 while (hasMore) {13 // ...14 await promiseAll(15 products.map(async (prod) => {16 const after = await sanityModule.upsertSyncDocument(17 "product", 18 prod as ProductDTO19 )20 21 upsertMap.push({22 // @ts-ignore23 before: prod.sanity_product,24 after,25 })26 27 return after28 })29 )30 31 offset += batchSize32 hasMore = offset < count33 total += products.length34 }35 36 return new StepResponse({ total }, upsertMap)37 }38)
In the while
loop, you loop over the array of products to sync them to Sanity. You use the promiseAll
Medusa utility that loops over an array of promises and ensures that all transactions within these promises are rolled back in case an error occurs.
For each product, you upsert it into Sanity, then push its document before and after the update to the upsertMap
. You'll learn more about its use later.
The step returns an instance of StepResponse
, which must be returned by any step. It accepts as a first parameter the data to return to the workflow that executed this step.
Add Compensation Function
StepResponse
accepts a second parameter, which is passed to the compensation function. A compensation function defines the rollback logic of a step, and it's only executed if an error occurs in the workflow. This eliminates data inconsistency if an error occurs and the workflow can't finish execution successfully.
The syncStep
creates or updates products in Sanity. So, the compensation function must delete created documents or revert the update of a document to its previous data. The compensation function is only executed if an error occurs.
To define the compensation function, pass a third-parameter to the createStep
function:
1export const syncStep = createStep(2 { name: "sync-step", async: true },3 async (input: SyncStepInput, { container }) => {4 // ...5 },6 async (upsertMap, { container }) => {7 if (!upsertMap) {8 return9 }10 11 const sanityModule: SanityModuleService = container.resolve(SANITY_MODULE)12 13 await promiseAll(14 upsertMap.map(({ before, after }) => {15 if (!before) {16 // delete the document17 return sanityModule.delete(after._id)18 }19 20 const { _id: id, ...oldData } = before21 22 return sanityModule.update(23 id,24 oldData25 )26 })27 )28 }29)
The compensation function accepts the data passed in the step's StepResponse
second parameter (in this case, upsertMap
), and an object of options similar to that of the step.
In the compensation function, you resolve the Sanity Module's service, then loop over the upsertMap
to delete created documents, or revert existing ones.
Create Workflow#
You'll now create the workflow that uses the syncStep
. This is the workflow that you'll later execute to sync data automatically or manually.
Workflows are created in a file under the src/workflows
directory. So, create the file src/workflows/sanity-sync-products/index.ts
with the following content:
1import {2 createWorkflow,3 WorkflowResponse,4} from "@medusajs/framework/workflows-sdk"5import { syncStep } from "./steps/sync"6 7export type SanitySyncProductsWorkflowInput = {8 product_ids?: string[];9};10 11export const sanitySyncProductsWorkflow = createWorkflow(12 { name: "sanity-sync-products", retentionTime: 10000 },13 function (input: SanitySyncProductsWorkflowInput) {14 const result = syncStep(input)15 16 return new WorkflowResponse(result)17 }18)
You create a workflow using the createWorkflow
function imported from @medusajs/framework/workflows-sdk
. It accepts an object of options as a first parameter, where the name
property is required and indicates the workflow's unique name.
The retentionTime
property indicates how long should the workflow's progress be saved in the database. This is useful if you later want to track whether the workflow is successfully executing.
createWorkflow
accepts as a second parameter a constructor function, which is the workflow's implementation. In the function, you execute the syncStep
to sync the specified products in the input, then return its result. Workflows must return an instance of WorkflowResponse
.
You'll execute and test this workflow in the next steps.
Step 7: Handle Product Changes in Medusa#
You've defined the workflow to sync the products. Now, you want to execute it when a product is created or updated.
Medusa emits events when certain actions occur, such as when a product is created. Then, you can listen to those events in a subscriber.
A subscriber is an asynchronous function that listens to one or more events. Then, when those events are emitted, the subscriber is executed in the background of your application.
Subscribers are useful when you want to perform an action that isn't an integral part of a flow, but as a reaction to a performed action. In this case, syncing the products to Sanity isn't integral to creating a product, so you do it in a subscriber after the product is created.
So, to run the workflow you defined in the previous event when a product is created or updated, you'll create a subscriber that listens to the product.created
and product.updated
events.
Subscribers are created under the src/subscribers
directory. So, create the file src/subscribers/sanity-product-sync.ts
with the following content:
1import type { 2 SubscriberArgs, 3 SubscriberConfig,4} from "@medusajs/medusa"5import { 6 sanitySyncProductsWorkflow,7} from "../workflows/sanity-sync-products"8 9export default async function upsertSanityProduct({10 event: { data },11 container,12}: SubscriberArgs<{ id: string }>) {13 await sanitySyncProductsWorkflow(container).run({14 input: {15 product_ids: [data.id],16 },17 })18}19 20export const config: SubscriberConfig = {21 event: ["product.created", "product.updated"],22}
The subscriber function upsertSanityProduct
accepts an object as a parameter that has the following properties:
event
: An object of the event's details. Itsdata
property holds the data payload emitted with the event, which in this case is the ID of the product created or updated.container
: An instance of the Medusa container to resolve resources.
In the subscriber, you execute the sanitySyncProductsWorkflow
by invoking it, passing it the container, then invoking its run
method. You pass the workflow's input in the input
property of the run
's object parameter.
The subscriber file must also export a configuration object. It has an event
property, which is the names of the events that the subscriber is listening to.
Test it Out#
To test it out, run the Medusa application, then open the Medusa Admin in your browser at http://localhost:9000/app
. Try creating or updating a product. You'll see the following message in the console:
This means that the product.created
event was emitted and your subscriber was executed.
In the next step, you'll setup Sanity with Next.js, and you can then monitor the updates in Sanity's studio.
Step 8: Setup Sanity with Next.js Starter Storefront#
In this step, you'll install Sanity in the Next.js Starter and configure it. You'll then have a Sanity studio in your Next.js storefront, where you'll later view the product documents being synced from Medusa, and update their content that you'll display in the storefront on the product details page.
Sanity has a CLI tool that helps you with the setup. First, change to the Next.js Starter's directory (it's outside the Medusa application's directory and its name is {project-name}-storefront
, where {project-name}
is the name of the Medusa application's directory).
Then, run the following command:
You'll then be asked a few questions:
- For the project, select the Sanity project you created earlier in this guide.
- For dataset, use
production
unless you changed it in the Sanity project. - Select yes for adding the Sanity configuration files to the Next.js folder.
- Select yes for TypeScript.
- Select yes for Sanity studio, and choose the
/studio
route. - Select clean project template.
- Select yes for adding the project ID and dataset to
.env.local
.
Afterwards, the command will install the necessary dependencies for Sanity.
Error during installation
Update Middleware#
The Next.js Starter storefront has a middleware that ensures all requests start with a country code (for example, /us
).
Since the Sanity studio runs at /studio
, the middleware should ignore requests to this path.
Open the file src/middleware.ts
and find the following if
condition:
Replace it with the following condition:
If the path starts with /studio
, the middleware will stop executing and the page will open.
Set CORS Settings#
Every Sanity project has a configured set of CORS origins allowed, with the default being http://localhost:3333
.
The Next.js Starter runs on the 8000
port, so you must add it to the allowed CORS origins.
In your Sanity project's dashboard:
- Click on the API tab.
- Scroll down to CORS origins and click the "Add CORS origin" button.
- Enter
http://localhost:8000
in the Origin field. - Enable the "Allow credentials" checkbox.
- Click the Save button.
Open Sanity Studio#
To open the Sanity studio, start the Next.js Starter's development server:
Then, open http://localhost:8000/studio
in your browser. The Sanity studio will open, but right now it's empty.
Step 9: Add Product Schema Type in Sanity#
In this step, you'll define the product
schema type in Sanity. You' can then view the documents of that schema in the studio and update their content.
To create the schema type, create the file src/sanity/schemaTypes/documents/product.ts
with the following content:
1import { ComposeIcon } from "@sanity/icons"2import { DocumentDefinition } from "sanity"3 4const productSchema: DocumentDefinition = {5 fields: [6 {7 name: "title",8 type: "string",9 },10 {11 group: "content",12 name: "specs",13 of: [14 {15 fields: [16 { name: "lang", title: "Language", type: "string" },17 { name: "title", title: "Title", type: "string" },18 {19 name: "content",20 rows: 3,21 title: "Content",22 type: "text",23 },24 ],25 name: "spec",26 type: "object",27 },28 ],29 type: "array",30 },31 {32 fields: [33 { name: "title", title: "Title", type: "string" },34 {35 name: "products",36 of: [{ to: [{ type: "product" }], type: "reference" }],37 title: "Addons",38 type: "array",39 validation: (Rule) => Rule.max(3),40 },41 ],42 name: "addons",43 type: "object",44 },45 ],46 name: "product",47 preview: {48 select: {49 title: "title",50 },51 },52 title: "Product Page",53 type: "document",54 groups: [{55 default: true,56 // @ts-ignore57 icon: ComposeIcon,58 name: "content",59 title: "Content",60 }],61}62 63export default productSchema
This creates a schema that has the following fields:
title
: The title of a document, which is in this case the product's type.specs
: An array of product specs. Each object in the array has the following fields:lang
: This is useful if you want to have localized content.title
: The product's title.content
: Textual content, such as the product's description.
addons
: An object of products related to this product.
When you sync the products from Medusa, you only sync the title. You manage the specs
and addons
fields within Sanity.
Next, replace the content of src/sanity/schemaTypes/index.ts
with the following:
1import { SchemaPluginOptions } from "sanity"2import productSchema from "./documents/product"3 4export const schema: SchemaPluginOptions = {5 types: [productSchema],6 templates: (templates) => templates.filter(7 (template) => template.schemaType !== "product"8 ),9}
You add the product schema to the list of exported schemas, but also disable creating a new product. You can only create the products in Medusa.
Test it Out#
To ensure that your schema is defined correctly and working, start the Next.js storefront's server, and open the Sanity studio again at http://localhost:8000/studio
.
You'll find "Product Page" under Content. If you click on it, you'll find any product you've synced from Medusa.
If you haven't synced any products yet or you want to see the live update, try now creating or updating a product in Medusa. You'll find it added in the Sanity studio.
If you click on any product, you can edit its existing field under "Specs" or add new ones. In the next section, you'll learn how to show the content in the "Specs" field on the storefront's product details page.
Step 10: Show Sanity Content in Next.js Starter Storefront#
Now that you're managing a product's content in Sanity, you want to show that content on the storefront. In this step, you'll customize the Next.js Starter storefront to show a product's content as defined in Sanity.
A product's details are retrieved in the file src/app/[countryCode]/(main)/products/[handle]/page.tsx
. So, replace the ProductPage
function with the following:
1// other imports...2import { client } from "../../../../../sanity/lib/client"3 4// ...5 6export default async function ProductPage({ params }: Props) {7 const region = await getRegion(params.countryCode)8 9 if (!region) {10 notFound()11 }12 13 const pricedProduct = await getProductByHandle(params.handle, region.id)14 if (!pricedProduct) {15 notFound()16 }17 18 // alternatively, you can filter the content by the language19 const sanity = (await client.getDocument(pricedProduct.id))?.specs[0]20 21 return (22 <ProductTemplate23 product={pricedProduct}24 region={region}25 countryCode={params.countryCode}26 sanity={sanity}27 />28 )29}
You import the Sanity client defined in src/sanity/lib/client.ts
(this was generated by Sanity's CLI). Then, in the page's function, you retrieve the product's document by ID and pass its first step to the ProductTemplate
component.
This is a simplified approach, but you can also have languages in your storefront and filter the spec based on the current language.
Next, you need to customize the ProductTemplate
to accept the sanity
prop. In the file src/modules/products/templates/index.tsx
add the following to ProductTemplateProps
:
Then, add the sanity
property to the expanded props of the component:
Finally, pass the sanity
prop to the ProductInfo
component in the return statement:
Next, you need to update the ProductInfo
component to accept and use the sanity
prop.
In src/modules/products/templates/product-info/index.tsx
, update the ProductInfoProps
to accept the sanity
prop:
Then, add the sanity
property to the expanded props of the component:
Next, find the following line in the return statement:
And replace it with the following:
Instead of showing the product's description on the product details page, this will show the content defined in Sanity if available.
Test it Out#
To test this out, first, run both the Next.js Starter storefront and the Medusa application, and open the Sanity studio. Try editing the content of the first spec of a product.
Then, open the Next.js Starter storefront at http://localhost:8000
and go to "Store" from the menu, then select the product you edited in Sanity.
In the product's page, you'll find under the product's name the content you put in Sanity.
You can now manage the product's content in Sanity, add more fields, and customize how you show them in the storefront. The Medusa application will also automatically create documents in Sanity for new products you add or update, ensuring your products are always synced across systems.
Step 11: Customize Admin to Manually Sync Data#
There are cases where you need to trigger the syncing of products manually, such as when an error occurs or you have products from before creating this integration.
The Medusa Admin dashboard is customizable, allowing you to either inject components, called widgets, into existing pages, or adding new pages, called UI routes. In these customizations, you can send requests to the Medusa application to perform custom operations.
In this step, you'll add a widget to the product's details page. In that page, you'll show whether a product is synced with Sanity, and allow the admin user to trigger syncing it manually.
Before you do that, however, you need two new API routes in your Medusa application: one to retrieve a document from Sanity, and one to trigger syncing the product data.
Get Sanity Document API Route#
In this section, you'll create the API route to retrieve a sanity document, and the URL to it in the Sanity studio.
To retrieve the URL to the Sanity studio, add the following method in the Sanity Module's service in src/modules/sanity/service.ts
:
1class SanityModuleService {2 // ...3 async getStudioLink(4 type: string,5 id: string,6 config: { explicit_type?: boolean } = {}7 ) {8 const resolvedType = config.explicit_type ? type : this.typeMap[type]9 if (!this.studioUrl) {10 throw new Error("No studio URL provided")11 }12 return `${this.studioUrl}/structure/${resolvedType};${id}`13 }14}
The method uses the studioUrl
property, which you set in the constructor
using the studio_url
module option, to get the studio link.
Then, to create the API route, create the file src/api/admin/sanity/documents/[id]/route.ts
with the following content:
1import { 2 MedusaRequest, 3 MedusaResponse,4} from "@medusajs/framework/http"5import SanityModuleService from "src/modules/sanity/service"6import { SANITY_MODULE } from "../../../../../modules/sanity"7 8export const GET = async (req: MedusaRequest, res: MedusaResponse) => {9 const { id } = req.params10 11 const sanityModule: SanityModuleService = req.scope.resolve(12 SANITY_MODULE13 )14 const sanityDocument = await sanityModule.retrieve(id)15 16 const url = sanityDocument ? 17 await sanityModule.getStudioLink(18 sanityDocument._type,19 sanityDocument._id,20 { explicit_type: true }21 )22 : ""23 24 res.json({ sanity_document: sanityDocument, studio_url: url })25}
This defines a GET
API route at /admin/sanity/documents/:id
, where :id
is a dynamic path parameter indicating the ID of a document to retrieve.
In the GET
route handler, you resolve the Sanity Module's service and use it to first retrieve the product's document, then the studio link of that document.
You return in the JSON response an object having the sanity_document
and studio_url
properties.
You'll test out this route in a later section.
Trigger Sanity Sync API Route#
In this section, you'll add the API route that manually triggers syncing a product to Sanity.
Since you already have the workflow to sync products, you only need to create an API route that executes it.
Create the file src/api/admin/sanity/documents/[id]/sync/route.ts
with the following content:
1import { 2 MedusaRequest, 3 MedusaResponse,4} from "@medusajs/framework/http"5import { 6 sanitySyncProductsWorkflow,7} from "../../../../../../workflows/sanity-sync-products"8 9export const POST = async (req: MedusaRequest, res: MedusaResponse) => {10 const { transaction } = await sanitySyncProductsWorkflow(req.scope)11 .run({12 input: { product_ids: [req.params.id] },13 })14 15 res.json({ transaction_id: transaction.transactionId })16}
You add a POST
API route at /admin/sanity/documents/:id/sync
, where :id
is a dynamic path parameter that indicates the ID of a product to sync to Sanity.
In the POST
API route handler, you execute the sanitySyncProductsWorkflow
, passing it the ID of the product from the path parameter as an input.
In the next section, you'll customize the admin dashboard and send requests to the API route from there.
Sanity Product Widget#
In this section, you'll add a widget in the product details page. The widget will show the Sanity document of the product and triggers syncing it to Sanity using the API routes you created.
To send requests from admin customizations to the Medusa server, you need to use Medusa's JS SDK. You'll also use Tanstack Query to benefit from features like data caching and invalidation.
To configure the JS SDK, create the file src/admin/lib/sdk.ts
with the following content:
You initialize the JS SDK and export it. You can learn more about configuring the JS SDK in this guide.
Next, you'll create hooks using Tanstack Query to send requests to the API routes you created earlier.
Create the file src/admin/hooks/sanity.tsx
with the following content:
1import { 2 useMutation, 3 UseMutationOptions, 4 useQueryClient, 5} from "@tanstack/react-query"6import { sdk } from "../lib/sdk"7 8export const useTriggerSanityProductSync = (9 id: string,10 options?: UseMutationOptions11) => {12 const queryClient = useQueryClient()13 14 return useMutation({15 mutationFn: () =>16 sdk.client.fetch(`/admin/sanity/documents/${id}/sync`, {17 method: "post",18 }),19 onSuccess: (data: any, variables: any, context: any) => {20 queryClient.invalidateQueries({21 queryKey: [`sanity_document`, `sanity_document_${id}`],22 })23 24 options?.onSuccess?.(data, variables, context)25 },26 ...options,27 })28}
You define the useTriggerSanityProductSync
hook which creates a Tanstack Query mutation that, when executed, sends a request to the API route that triggers syncing the product to Sanity.
Add in the same file another hook:
1// other imports...2import { 3 // ...4 QueryKey, 5 useQuery, 6 UseQueryOptions,7} from "@tanstack/react-query"8import { FetchError } from "@medusajs/js-sdk"9 10// ...11 12export const useSanityDocument = (13 id: string,14 query?: Record<any, any>,15 options?: Omit<16 UseQueryOptions<17 Record<any, any>,18 FetchError,19 { sanity_document: Record<any, any>; studio_url: string },20 QueryKey21 >,22 "queryKey" | "queryFn"23 >24) => {25 const fetchSanityProductStatus = async (query?: Record<any, any>) => {26 return await sdk.client.fetch<Record<any, any>>(27 `/admin/sanity/documents/${id}`,28 {29 query,30 }31 )32 }33 34 const { data, ...rest } = useQuery({35 queryFn: async () => fetchSanityProductStatus(query),36 queryKey: [`sanity_document_${id}`],37 ...options,38 })39 40 return { ...data, ...rest }41}
You define the hook useSanityDocument
which retrieves the Sanity document of a product using Tankstack Query.
You can now create the widget injected in a product's details page. Widgets are react components created in a file under the src/admin/widgets
directory.
So, create the file src/admin/widgets/sanity-product.tsx
with the following content:
1import { defineWidgetConfig } from "@medusajs/admin-sdk"2import { AdminProduct, DetailWidgetProps } from "@medusajs/types"3import { ArrowUpRightOnBox } from "@medusajs/icons"4import { Button, CodeBlock, Container, StatusBadge, toast } from "@medusajs/ui"5import { useState } from "react"6import {7 useSanityDocument,8 useTriggerSanityProductSync,9} from "../hooks/sanity"10 11const ProductWidget = ({ data }: DetailWidgetProps<AdminProduct>) => {12 const { mutateAsync, isPending } = useTriggerSanityProductSync(data.id)13 const { sanity_document, studio_url, isLoading } = useSanityDocument(data.id)14 const [showCodeBlock, setShowCodeBlock] = useState(false)15 16 const handleSync = async () => {17 try {18 await mutateAsync(undefined)19 toast.success(`Sync triggered.`)20 } catch (err) {21 toast.error(`Couldn't trigger sync: ${22 (err as Record<string, unknown>).message23 }`)24 }25 }26 27 return (28 <Container>29 <div className="flex justify-between w-full items-center">30 <div className="flex gap-2 items-center">31 <h2>Sanity Status</h2>32 <div>33 {isLoading ? (34 "Loading..."35 ) : sanity_document?.title === data.title ? (36 <StatusBadge color="green">Synced</StatusBadge>37 ) : (38 <StatusBadge color="red">Not Synced</StatusBadge>39 )}40 </div>41 </div>42 <Button43 size="small"44 variant="secondary"45 onClick={handleSync}46 disabled={isPending}47 >48 Sync49 </Button>50 </div>51 <div className="mt-6">52 <div className="mb-4 flex gap-4">53 <Button54 size="small"55 variant="secondary"56 onClick={() => setShowCodeBlock(!showCodeBlock)}57 >58 {showCodeBlock ? "Hide" : "Show"} Sanity Document59 </Button>60 {studio_url && (61 <a href={studio_url} target="_blank" rel="noreferrer">62 <Button variant="transparent">63 <ArrowUpRightOnBox /> Sanity Studio64 </Button>65 </a>66 )}67 </div>68 {!isLoading && showCodeBlock && (69 <CodeBlock70 className="dark"71 snippets={[72 {73 language: "json",74 label: "Sanity Document",75 code: JSON.stringify(sanity_document, null, 2),76 },77 ]}78 >79 <CodeBlock.Body />80 </CodeBlock>81 )}82 </div>83 </Container>84 )85}86 87// The widget's configurations88export const config = defineWidgetConfig({89 zone: "product.details.after",90})91 92export default ProductWidget
The file exports a ProductWidget
component and a config
object created with the defineWidgetConfig
utility function. In the config
object, you specify the zone to inject the widget into in the zone
property.
In the widget, you use the useSanityDocument
to retrieve the product's document from Sanity by sending a request to the API route you created earlier. You show that document's details and a button to trigger syncing the data.
When the "Sync" button is clicked, you use the useTriggerSanityProductSync
hook which sends a request to the API route you created earlier and executes the workflow that syncs the product to Sanity. The workflow will execute in the background, since you configured its step to be async.
To render a widget that matches the rest of the admin dashboard's design, you use components from the Medusa UI package, such as the CodeBlock
or Container
components.
Test it Out#
To test these customizations out, start the Medusa application and open the admin dashboard. Then, choose a product and scroll down to the end of the page.
You'll find a new "Sanity Status" section showing you whether the product is synced to Sanity and its document's details. You can also click the Sync button, which will sync the product to Sanity.
Step 12: Add Track Syncs Page to Medusa Admin#
Earlier in this guide when introducing workflows, you learned that you can track the execution of a workflow. As a last step of this guide, you'll add a new page in the admin dashboard that shows the executions of the sanitySyncProductsWorkflow
and their status. You'll also add the ability to sync all products to Sanity from that page.
Retrieve Sync Executions API Route#
Medusa has a workflow engine that manages workflow executions, roll-backs, and other functionalities under the hood.
The workflow engine is an architectural module, which can be replaced with a Redis Workflow Engine, or a custom one of your choice, allowing you to take ownership of your application's tooling.
In your customizations, you can resolve the workflow engine from the container and manage executions of a workflow, such as retrieve them and check their progress.
In this section, you'll create an API route to retrieve the stored executions of the sanitySyncProductsWorkflow
workflow, so that you can display them later on the dashboard.
Create the file src/api/admin/sanity/syncs/route.ts
with the following content:
1import { MedusaRequest, MedusaResponse } from "@medusajs/framework"2import { Modules } from "@medusajs/framework/utils"3import { 4 sanitySyncProductsWorkflow,5} from "../../../../workflows/sanity-sync-products"6 7export const GET = async (req: MedusaRequest, res: MedusaResponse) => {8 const workflowEngine = req.scope.resolve(9 Modules.WORKFLOW_ENGINE10 )11 12 const [executions, count] = await workflowEngine13 .listAndCountWorkflowExecutions(14 {15 workflow_id: sanitySyncProductsWorkflow.getName(),16 },17 { order: { created_at: "DESC" } }18 )19 20 res.json({ workflow_executions: executions, count })21}
You add a GET
API route at /admin/sanity/syncs
. In the API route handler, you resolve the Workflow Engine Module's service from the Medusa container. You use the listAndCountWorkflowExecutions
method to retrieve the executions of the sanitySyncProductsWorkflow
workflow, filtering by its name.
You return the executions in the JSON response of the route.
Trigger Sync API Route#
In this section, you'll add another API route that triggers syncing all products to Sanity.
In the same file src/api/admin/sanity/syncs/route.ts
, add the following:
This adds a POST
API route at /admin/sanity/syncs
. In the route handler, you execute the sanitySyncProductsWorkflow
without passing it a product_ids
input. The step in the workflow will retrieve all products, instead of filtering them by ID, and sync them to Sanity.
You return the transaction ID of the workflow, which you can use to track the execution's progress since the workflow will run in the background. This is not implemented in this guide, but Medusa has a Get Execution API route that you can use to get the details of a workflow's execution.
Add Sanity UI Route#
In this section, you'll add a UI route in the admin dashboard, which is a new page, that shows the list of sanitySyncProductsWorkflow
executions and allows triggering sync of all products in Medusa.
A UI route is React component exported in a file under the src/admin/routes
directory. Similar to a widget, a UI route can also send requests to the Medusa application to perform actions using your custom API routes.
Before creating the UI route, you'll create hooks using Tanstack Query that send requests to these UI routes. In the file src/admin/hooks/sanity.tsx
, add the following two new hooks:
1export const useTriggerSanitySync = (options?: UseMutationOptions) => {2 const queryClient = useQueryClient()3 4 return useMutation({5 mutationFn: () =>6 sdk.client.fetch(`/admin/sanity/syncs`, {7 method: "post",8 }),9 onSuccess: (data: any, variables: any, context: any) => {10 queryClient.invalidateQueries({11 queryKey: [`sanity_sync`],12 })13 14 options?.onSuccess?.(data, variables, context)15 },16 ...options,17 })18}19 20export const useSanitySyncs = (21 query?: Record<any, any>,22 options?: Omit<23 UseQueryOptions<24 Record<any, any>,25 FetchError,26 { workflow_executions: Record<any, any>[] },27 QueryKey28 >,29 "queryKey" | "queryFn"30 >31) => {32 const fetchSanitySyncs = async (query?: Record<any, any>) => {33 return await sdk.client.fetch<Record<any, any>>(`/admin/sanity/syncs`, {34 query,35 })36 }37 38 const { data, ...rest } = useQuery({39 queryFn: async () => fetchSanitySyncs(query),40 queryKey: [`sanity_sync`],41 ...options,42 })43 44 return { ...data, ...rest }45}
The useTriggerSanitySync
hook creates a mutation that, when executed, sends a request to the trigger sync API route you created earlier to sync all products.
The useSanitySyncs
hook sends a request to the retrieve sync executions API route that you created earlier to retrieve the workflow's exections.
Finally, to create the UI route, create the file src/admin/routes/sanity/page.tsx
with the following content:
1import { defineRouteConfig } from "@medusajs/admin-sdk"2import { Sanity } from "@medusajs/icons"3import {4 Badge,5 Button,6 Container,7 Heading,8 Table,9 Toaster,10 toast,11} from "@medusajs/ui"12import { useSanitySyncs, useTriggerSanitySync } from "../../hooks/sanity"13 14const SanityRoute = () => {15 const { mutateAsync, isPending } = useTriggerSanitySync()16 const { workflow_executions, refetch } = useSanitySyncs()17 18 const handleSync = async () => {19 try {20 await mutateAsync()21 toast.success(`Sync triggered.`)22 refetch()23 } catch (err) {24 toast.error(`Couldn't trigger sync: ${25 (err as Record<string, unknown>).message26 }`)27 }28 }29 30 const getBadgeColor = (state: string) => {31 switch (state) {32 case "invoking":33 return "blue"34 case "done":35 return "green"36 case "failed":37 return "red"38 default:39 return "grey"40 }41 }42 43 return (44 <>45 <Container className="flex flex-col p-0 overflow-hidden">46 <div className="p-6 flex justify-between">47 <Heading className="font-sans font-medium h1-core">48 Sanity Syncs49 </Heading>50 <Button51 variant="secondary"52 size="small"53 onClick={handleSync}54 disabled={isPending}55 >56 Trigger Sync57 </Button>58 </div>59 <Table>60 <Table.Header>61 <Table.Row>62 <Table.HeaderCell>Sync ID</Table.HeaderCell>63 <Table.HeaderCell>Status</Table.HeaderCell>64 <Table.HeaderCell>Created At</Table.HeaderCell>65 <Table.HeaderCell>Updated At</Table.HeaderCell>66 </Table.Row>67 </Table.Header>68 69 <Table.Body>70 {(workflow_executions || []).map((execution) => (71 <Table.Row72 key={execution.id}73 className="cursor-pointer"74 onClick={() =>75 (window.location.href = `/app/sanity/${execution.id}`)76 }77 >78 <Table.Cell>{execution.id}</Table.Cell>79 <Table.Cell>80 <Badge81 rounded="full"82 size="2xsmall"83 color={getBadgeColor(execution.state)}84 >85 {execution.state}86 </Badge>87 </Table.Cell>88 <Table.Cell>{execution.created_at}</Table.Cell>89 <Table.Cell>{execution.updated_at}</Table.Cell>90 </Table.Row>91 ))}92 </Table.Body>93 </Table>94 </Container>95 <Toaster />96 </>97 )98}99 100export const config = defineRouteConfig({101 label: "Sanity",102 icon: Sanity,103})104 105export default SanityRoute
The file's path relative to the src/admin/routes
directory indicates its path in the admin dashboard. So, this adds a new route at the path http://localhost:9000/app/sanity
.
The file must export the UI route's component. Also, to add an item in the sidebar for the UI route, you export a configuration object, created with the defineRouteConfig
utility function. The function accepts the following properties:
label
: The sidebar item's label.icon
: The icon to the show in the sidebar.
In the UI route, you use the useSanitySyncs
hook to retrieve the list of sync executions and display them with their status. You also show a "Trigger Sync" button that, when clicked, uses the mutation from the useTriggerSanitySync
hook to send a request to the Medusa application and trigger the sync.
To display components that match the design of the Medusa Admin, you use components from the Medusa UI package.
Test it Out#
To test it out, start the Medusa application and open the admin dashboard. After logging in, you'll find a new "Sanity" item in the sidebar.
If you click on it, you'll see a table of the latest syncs. You also trigger syncing by clicking the "Trigger Sync" button. After you click the button, you should see a new execution added to the table.
Next Steps#
You've now integrated Medusa with Sanity and can benefit from powerful commerce and CMS features.
If you're new to Medusa, check out the main documentation, where you'll get a more in-depth learning of all the concepts you've used in this guide and more.
To learn more about the commerce features that Medusa provides, check out Medusa's Commerce Modules.
For other general guides related to deployment, storefront development, integrations, and more, check out the Development Resources.