In this guide, you’ll learn how to build the onboarding widget available in the admin dashboard the first time you install a Medusa project.
The onboarding widget is already implemented within the codebase of your Medusa backend. This guide is helpful if you want to understand how it was implemented or you want an example of customizing the Medusa admin and backend.
Build an onboarding flow in the admin that takes the user through creating a sample product and order. This flow has four steps and navigates the user between four pages in the admin before completing the guide. This will be implemented using Admin Widgets.
Keep track of the current step the user has reached by creating a table in the database and an API endpoint that the admin widget uses to retrieve and update the current step. These customizations will be applied to the backend.
Before you follow along this tutorial, you must have a Medusa backend installed with the betaCopy to Clipboard version of the @medusajs/adminCopy to Clipboard package. If not, you can use the following command to get started:
npx create-medusa-app@latest
Report Incorrect CodeCopy to Clipboard
Please refer to the create-medusa-app documentation for more details on this command, including prerequisites and troubleshooting.
The steps in this section are used to prepare for the custom functionalities you’ll be creating in this tutorial.
(Optional) TypeScript Configurations and package.json
If you're using TypeScript in your project, it's highly recommended to setup your TypeScript configurations and package.json as mentioned in this guide.
Medusa React is a React library that facilitates using Medusa’s endpoints within your React application. It also provides the utility to register and use custom endpoints.
To install Medusa React and its required dependencies, run the following command in the root directory of the Medusa backend:
A service is a class that holds helper methods related to an entity. For example, methods to create or retrieve a record of that entity. Services are used by other resources, such as endpoints, to perform functionalities related to an entity.
So, before you add the endpoints that allow retrieving and updating the onboarding state, you need to add the service that implements these helper functionalities.
Start by creating the file src/types/onboarding.tsCopy to Clipboard with the following content:
This service class implements two methods retrieveCopy to Clipboard to retrieve the current onboarding state, and updateCopy to Clipboard to update the current onboarding state.
The last part of this step is to create the endpoints that you’ll consume in the admin widget. There will be two endpoints: Get Onboarding State and Update Onboarding State.
To add the Get Onboarding State endpoint, create the file src/api/routes/admin/onboarding/get-status.tsCopy to Clipboard with the following content:
Notice how this endpoint uses the OnboardingServiceCopy to Clipboard's retrieveCopy to Clipboard method to retrieve the current onboarding state. It resolves the OnboardingServiceCopy to Clipboard using the Dependency Container.
To add the Update Onboarding State, create the file src/api/routes/admin/onboarding/update-status.tsCopy to Clipboard with the following content:
This file creates a router that registers the Get Onboarding State and Update Onboarding State endpoints.
Next, create or change the content of the file src/api/routes/admin/index.tsCopy to Clipboard to the following:
src/api/routes/admin/index.ts
import{ Router }from"express" import{ wrapHandler }from"@medusajs/medusa" import onboardingRoutes from"./onboarding" import customRouteHandler from"./custom-route-handler" // Initialize a custom router const router =Router() exportfunctionattachAdminRoutes(adminRouter: Router){ // Attach our router to a custom path on the admin router adminRouter.use("/custom", router) // Define a GET endpoint on the root route of our custom path router.get("/",wrapHandler(customRouteHandler)) // Attach routes for onboarding experience, defined separately onboardingRoutes(adminRouter) }
Report Incorrect CodeCopy to Clipboard
This file exports the router created in src/api/routes/admin/onboarding/index.tsCopy to Clipboard.
Finally, create or change the content of the file src/api/index.tsCopy to Clipboard to the following content:
src/api/index.ts
import{ Router }from"express" import cors from"cors" import bodyParser from"body-parser" import{ authenticate, ConfigModule }from"@medusajs/medusa" import{ getConfigFile }from"medusa-core-utils" import{ attachStoreRoutes }from"./routes/store" import{ attachAdminRoutes }from"./routes/admin" exportdefault(rootDirectory:string): Router | Router[]=>{ // Read currently-loaded medusa config const{ configModule }=getConfigFile<ConfigModule>( rootDirectory, "medusa-config" ) const{ projectConfig }= configModule // Set up our CORS options objects, based on config const storeCorsOptions ={ origin: projectConfig.store_cors.split(","), credentials:true, } const adminCorsOptions ={ origin: projectConfig.admin_cors.split(","), credentials:true, } // Set up express router const router =Router() // Set up root routes for store and admin endpoints, // with appropriate CORS settings router.use( "/store", cors(storeCorsOptions), bodyParser.json() ) router.use( "/admin", cors(adminCorsOptions), bodyParser.json() ) // Add authentication to all admin routes *except* // auth and account invite ones router.use( /\/admin\/((?!auth)(?!invites).*)/, authenticate() ) // Set up routers for store and admin endpoints const storeRouter =Router() const adminRouter =Router() // Attach these routers to the root routes router.use("/store", storeRouter) router.use("/admin", adminRouter) // Attach custom routes to these routers attachStoreRoutes(storeRouter) attachAdminRoutes(adminRouter) return router }
Report Incorrect CodeCopy to Clipboard
This is the file that the Medusa core loads the endpoints from. In this file, you export a router that registers store and admin endpoints, including the onboardingCopy to Clipboard endpoints you just added.
importReact,{ useState, useEffect, }from"react" import{ Container, }from"../../components/shared/container" importButtonfrom"../../components/shared/button" import{ WidgetConfig, }from"@medusajs/admin" importAccordionfrom"../../components/shared/accordion" importGetStartedIconfrom"../../components/shared/icons/get-started-icon" import{ OnboardingState, }from"../../../models/onboarding" import{ useNavigate, }from"react-router-dom" import{ AdminOnboardingUpdateStateReq, OnboardingStateRes, UpdateOnboardingStateInput, }from"../../../types/onboarding" typeSTEP_ID= |"create_product" |"preview_product" |"create_order" |"setup_finished"; exporttypeStepContentProps=any&{ onNext?:Function; isComplete?:boolean; data?:OnboardingState; }&any; typeStep={ id:STEP_ID; title:string; component:React.FC<StepContentProps>; onNext?:Function; }; constSTEP_FLOW:STEP_ID[]=[ "create_product", "preview_product", "create_order", "setup_finished", ] constOnboardingFlow=(props:any)=>{ const navigate =useNavigate() // TODO change based on state in backend const currentStep:STEP_ID|undefined="create_product"asSTEP_ID const[openStep, setOpenStep]=useState(currentStep) const[completed, setCompleted]=useState(false) useEffect(()=>{ setOpenStep(currentStep) if(currentStep ===STEP_FLOW[STEP_FLOW.length-1]){setCompleted(true)} },[currentStep]) constupdateServerState=(payload:any)=>{ // TODO update state in the backend } constonStart=()=>{ // TODO update state in the backend navigate(`/a/products`) } constsetStepComplete=({ step_id, extraData, onComplete, }:{ step_id:STEP_ID; extraData?:UpdateOnboardingStateInput; onComplete?:()=>void; })=>{ // TODO update state in the backend } constgoToProductView=(product:any)=>{ setStepComplete({ step_id:"create_product", extraData:{ product_id: product.id}, onComplete:()=>navigate(`/a/products/${product.id}`), }) } constgoToOrders=()=>{ setStepComplete({ step_id:"preview_product", onComplete:()=>navigate(`/a/orders`), }) } constgoToOrderView=(order:any)=>{ setStepComplete({ step_id:"create_order", onComplete:()=>navigate(`/a/orders/${order.id}`), }) } constonComplete=()=>{ setCompleted(true) } constonHide=()=>{ updateServerState({ is_complete:true}) } // TODO add steps constSteps:Step[]=[] constisStepComplete=(step_id:STEP_ID)=> STEP_FLOW.indexOf(currentStep)>STEP_FLOW.indexOf(step_id) return( <> <Container> <Accordion type="single" className="my-3" value={openStep} onValueChange={(value)=>setOpenStep(value asSTEP_ID)} > <divclassName="flex items-center"> <divclassName="mr-5"> <GetStartedIcon/> </div> {!completed ?( <> <div> <h1className="font-semibold text-lg">Get started</h1> <p> Learn the basics of Medusa by creating your first order. </p> </div> <divclassName="ml-auto flex items-start gap-2"> {currentStep ?( <> {currentStep ===STEP_FLOW[STEP_FLOW.length-1]?( <Button variant="primary" size="small" onClick={()=>onComplete()} > Complete Setup </Button> ):( <Button variant="secondary" size="small" onClick={()=>onHide()} > Cancel Setup </Button> )} </> ):( <> <Button variant="secondary" size="small" onClick={()=>onHide()} > Close </Button> <Button variant="primary" size="small" onClick={()=>onStart()} > Begin setup </Button> </> )} </div> </> ):( <> <div> <h1className="font-semibold text-lg"> Thank you for completing the setup guide! </h1> <p> This whole experience was built using our new{" "} <strong>widgets</strong> feature. <br/> You can find out more details and build your own by following{" "} <a href="https://docs.medusajs.com/" target="_blank" className="text-blue-500 font-semibold"rel="noreferrer" > our guide </a> . </p> </div> <divclassName="ml-auto flex items-start gap-2"> <Button variant="secondary" size="small" onClick={()=>onHide()} > Close </Button> </div> </> )} </div> { <divclassName="mt-5"> {(!completed ?Steps:Steps.slice(-1)).map((step, index)=>{ const isComplete =isStepComplete(step.id) const isCurrent = currentStep === step.id return( <Accordion.Item title={step.title} value={step.id} headingSize="medium" active={isCurrent} complete={isComplete} disabled={!isComplete &&!isCurrent} key={index} {...(!isComplete&& !isCurrent&&{ customTrigger:<></>, })} > <divclassName="py-3 px-11 text-gray-500"> <step.component onNext={step.onNext} isComplete={isComplete} // TODO pass data {...props} /> </div> </Accordion.Item> ) })} </div> } </Accordion> </Container> </> ) } exportconst config:WidgetConfig={ zone:[ "product.list.before", "product.details.before", "order.list.before", "order.details.before", ], } exportdefaultOnboardingFlow
Report Incorrect CodeCopy to Clipboard
There are three important details to ensure that Medusa reads this file as a widget:
The file is placed under the src/admin/widgetCopy to Clipboard directory.
The file exports a configCopy to Clipboard object of type WidgetConfigCopy to Clipboard, which is imported from @medusajs/adminCopy to Clipboard.
The file default exports a React component, which in this case is OnboardingFlowCopy to Clipboard
The extension uses react-router-domCopy to Clipboard, which is available as a dependency of the @medusajs/adminCopy to Clipboard package, to navigate to other pages in the dashboard.
The OnboardingFlowCopy to Clipboard widget also implements functionalities related to handling the steps of the onboarding flow, including navigating between them and updating the current step in the backend. Some parts are left as TODOCopy to Clipboard until you add the components for each step, and you implement customizations in the backend.
In this section, you’ll create the components for each step in the onboarding flow. You’ll then update the OnboardingFlowCopy to Clipboard widget to use these components.
ProductsList component
The ProductsListCopy to Clipboard component is used in the first step of the onboarding widget. It allows the user to either open the Create Product modal or create a sample product.
Create the file src/admin/components/onboarding-flow/products/products-list.tsxCopy to Clipboard with the following content:
importReactfrom"react" importButtonfrom"../../shared/button" import{ useAdminCreateProduct, useAdminCreateCollection, }from"medusa-react" import{ useAdminRegions, }from"medusa-react" import{ StepContentProps, }from"../../../widgets/onboarding-flow/onboarding-flow" enumProductStatus{ PUBLISHED="published", } constProductsList=({ onNext, isComplete }:StepContentProps)=>{ const{ mutateAsync: createCollection, isLoading: collectionLoading }= useAdminCreateCollection() const{ mutateAsync: createProduct, isLoading: productLoading }= useAdminCreateProduct() const{ regions }=useAdminRegions() const isLoading = collectionLoading || productLoading constcreateSample=async()=>{ try{ const{ collection }=awaitcreateCollection({ title:"Merch", handle:"merch", }) const{ product }=awaitcreateProduct({ title:"Medusa T-Shirt", description:"Comfy t-shirt with Medusa logo", subtitle:"Black", is_giftcard:false, discountable:false, options:[{ title:"Size"}], images:[ "https://medusa-public-images.s3.eu-west-1.amazonaws.com/tee-black-front.png", "https://medusa-public-images.s3.eu-west-1.amazonaws.com/tee-black-back.png", ], collection_id: collection.id, variants:[ { title:"Small", inventory_quantity:25, manage_inventory:true, prices: regions.map((region)=>({ amount:5000, currency_code: region.currency_code, })), options:[{ value:"S"}], }, { title:"Medium", inventory_quantity:10, manage_inventory:true, prices: regions.map((region)=>({ amount:5000, currency_code: region.currency_code, })), options:[{ value:"M"}], }, { title:"Large", inventory_quantity:17, manage_inventory:true, prices: regions.map((region)=>({ amount:5000, currency_code: region.currency_code, })), options:[{ value:"L"}], }, { title:"Extra Large", inventory_quantity:22, manage_inventory:true, prices: regions.map((region)=>({ amount:5000, currency_code: region.currency_code, })), options:[{ value:"XL"}], }, ], status:ProductStatus.PUBLISHED, }) onNext(product) }catch(e){ console.error(e) } } return( <div> <p> Create a product and set its general details such as title and description, its price, options, variants, images, and more. You'll then use the product to create a sample order. </p> <p> If you're not ready to create a product, we can create a sample product for you. </p> {!isComplete &&( <divclassName="flex gap-2 mt-4"> <Button variant="secondary" size="small" onClick={()=>createSample()} loading={isLoading} > Create sample product </Button> </div> )} </div> ) } exportdefaultProductsList
Report Incorrect CodeCopy to Clipboard
ProductDetail component
The ProductDetailCopy to Clipboard component is used in the second step of the onboarding. It shows the user a code snippet to preview the product they created in the first step.
Create the file src/admin/components/onboarding-flow/products/product-detail.tsxCopy to Clipboard with the following content:
importReactfrom"react" importButtonfrom"../../shared/button" import{ useAdminProduct, }from"medusa-react" import{ useAdminCreateDraftOrder, }from"medusa-react" import{ useAdminShippingOptions, }from"medusa-react" import{ useAdminRegions, }from"medusa-react" import{ useMedusa, }from"medusa-react" import{ StepContentProps, }from"../../../widgets/onboarding-flow/onboarding-flow" constOrdersList=({ onNext, isComplete, data }:StepContentProps)=>{ const{ product }=useAdminProduct(data.product_id) const{ mutateAsync: createDraftOrder, isLoading }= useAdminCreateDraftOrder() const{ client }=useMedusa() const{ regions }=useAdminRegions() const{ shipping_options }=useAdminShippingOptions() constcreateOrder=async()=>{ const variant = product.variants[0]??null try{ const{ draft_order }=awaitcreateDraftOrder({ email:"customer@medusajs.com", items:[ variant ?{ quantity:1, variant_id: variant.id, } :{ quantity:1, title: product.title, unit_price:50, }, ], shipping_methods:[ { option_id: shipping_options[0].id, }, ], region_id: regions[0].id, }) const{ order }=await client.admin.draftOrders.markPaid(draft_order.id) onNext(order) }catch(e){ console.error(e) } } return( <> <divclassName="py-4"> <p> With a Product created, we can now place an Order. Click the button below to create a sample order. </p> </div> <divclassName="flex gap-2"> {!isComplete &&( <Button variant="primary" size="small" onClick={()=>createOrder()} loading={isLoading} > Create a sample order </Button> )} </div> </> ) } exportdefaultOrdersList
Report Incorrect CodeCopy to Clipboard
OrderDetail component
The OrderDetailCopy to Clipboard component is used in the fourth and final step of the onboarding. It educates the user on the next steps when developing with Medusa.
Create the file src/admin/components/onboarding-flow/orders/order-detail.tsxCopy to Clipboard with the following content:
In this section, you’ll implement the TODOCopy to Clipboards in the OnboardingFlowCopy to Clipboard that require communicating with the backend.
There are different ways you can consume custom backend endpoints. The Medusa React library provides utility methods that allow you to create hooks similar to those available by default in the library. You can then utilize these hooks to send requests to custom backend endpoints.
Add the following imports at the top of src/admin/widgets/onboarding-flow/onboarding-flow.tsxCopy to Clipboard:
Learn more about the available custom hooks such as useAdminCustomPostCopy to Clipboard and useAdminCustomQueryCopy to Clipboard in the Medusa React documentation.
dataCopy to Clipboard now holds the current onboarding state from the backend, and mutateCopy to Clipboard can be used to update the onboarding state in the backend.
After that, replace the declarations within OnboardingFlowCopy to Clipboard that had a TODOCopy to Clipboard comment with the following:
currentStepCopy to Clipboard now holds the current step retrieve from the backend; updateServerStateCopy to Clipboard updates the current step in the backend; onStartCopy to Clipboard updates the current step in the backend to the first step; and setStepCompleteCopy to Clipboard completes the current step by updating the current step in the backend to the following step.
Finally, in the returned JSX, update the TODOCopy to Clipboard in the <step.component>Copy to Clipboard component to pass the component the necessary dataCopy to Clipboard:
You’ve now implemented everything necessary for the onboarding flow! You can test it out by building the changes and running the developCopy to Clipboard command:
npm
Yarn
pnpm
npm run build npx @medusajs/medusa-cli develop
Report Incorrect CodeCopy to Clipboard
yarn build npx @medusajs/medusa-cli develop
Report Incorrect CodeCopy to Clipboard
pnpm run build npx @medusajs/medusa-cli develop
Report Incorrect CodeCopy to Clipboard
If you open the admin at localhost:7001Copy to Clipboard and log in, you’ll see the onboarding widget in the Products listing page. You can try using it and see your implementation in action!