Skip to main content
Skip to main content

Example: How to Create Onboarding Widget

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.

Note

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.

What you’ll be Building

By following this tutorial, you’ll:

  • 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 Route that the admin widget uses to retrieve and update the current step. These customizations will be applied to the backend.

Onboarding Widget Demo


Prerequisites

Before you follow along this tutorial, you must have a Medusa backend installed. If not, you can use the following command to get started:

npx create-medusa-app@latest

Please refer to the create-medusa-app documentation for more details on this command, including prerequisites and troubleshooting.


Preparation Steps

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.

Install Medusa React

Medusa React is a React library that facilitates using Medusa’s API Routes within your React application. It also provides the utility to register and use custom API Routes.

To install Medusa React and its required dependencies, run the following command in the root directory of the Medusa backend:

npm install medusa-react @tanstack/react-query@4.22

Implement Helper Resources

The resources in this section are used for typing, layout, and design purposes, and they’re used in other essential components in this tutorial.

Each of the collapsible elements below hold the path to the file that you should create, and the content of that file.

src/admin/types/icon-type.ts
src/admin/components/shared/icons/get-started.tsx
src/admin/components/shared/icons/active-circle-dotted-line.tsx
src/admin/components/shared/accordion.tsx
src/admin/components/shared/card.tsx

Step 1: Customize Medusa Backend

Note

If you’re not interested in learning about backend customizations, you can skip to step 2.

In this step, you’ll customize the Medusa backend to:

  1. Add a new table in the database that stores the current onboarding step. This requires creating a new entity, repository, and migration.
  2. Add new API Routes that allow retrieving and updating the current onboarding step. This also requires creating a new service.

Create Entity

An entity represents a table in the database. It’s based on Typeorm, so it requires creating a repository and a migration to be used in the backend.

To create the entity, create the file src/models/onboarding.ts with the following content:

src/models/onboarding.ts
import { BaseEntity } from "@medusajs/medusa"
import { Column, Entity } from "typeorm"

@Entity()
export class OnboardingState extends BaseEntity {
@Column()
current_step: string

@Column()
is_complete: boolean

@Column()
product_id: string
}

Then, create the file src/repositories/onboarding.ts that holds the repository of the entity with the following content:

src/repositories/onboarding.ts
import {
dataSource,
} from "@medusajs/medusa/dist/loaders/database"
import { OnboardingState } from "../models/onboarding"

const OnboardingRepository = dataSource.getRepository(
OnboardingState
)

export default OnboardingRepository

You can learn more about entities and repositories in this documentation.

Create Migration

A migration is used to reflect database changes in your database schema.

To create a migration, run the following command in the root of your Medusa backend:

npx typeorm migration:create src/migrations/CreateOnboarding

This will create a file in the src/migrations directory with the name formatted as <TIMESTAMP>-CreateOnboarding.ts.

In that file, import the generateEntityId utility method at the top of the file:

import { generateEntityId } from "@medusajs/utils"

Then, replace the up and down methods in the migration class with the following content:

export class CreateOnboarding1685715079776 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "onboarding_state" ("id" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "current_step" character varying NULL, "is_complete" boolean, "product_id" character varying NULL)`
)

await queryRunner.query(
`INSERT INTO "onboarding_state" ("id", "current_step", "is_complete") VALUES ('${generateEntityId(
"",
"onboarding"
)}' , NULL, false)`
)
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "onboarding_state"`)
}
}
Warning

Don’t copy the name of the class in the code snippet above. Keep the name you have in the file.

Finally, to reflect the migration in the database, run the build and migration commands:

npm run build
npx medusa migrations run

You can learn more about migrations in this guide.

Create Service

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 API Routes, to perform functionalities related to an entity.

So, before you add the API Routes 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.ts with the following content:

src/types/onboarding.ts
import { OnboardingState } from "../models/onboarding"

export type UpdateOnboardingStateInput = {
current_step?: string;
is_complete?: boolean;
product_id?: string;
};

export interface AdminOnboardingUpdateStateReq {}

export type OnboardingStateRes = {
status: OnboardingState;
};

This file holds the necessary types that will be used within the service you’ll create, and later in your onboarding flow widget.

Then, create the file src/services/onboarding.ts with the following content:

src/services/onboarding.ts
import { TransactionBaseService } from "@medusajs/medusa"
import OnboardingRepository from "../repositories/onboarding"
import { OnboardingState } from "../models/onboarding"
import { EntityManager, IsNull, Not } from "typeorm"
import { UpdateOnboardingStateInput } from "../types/onboarding"

type InjectedDependencies = {
manager: EntityManager;
onboardingRepository: typeof OnboardingRepository;
};

class OnboardingService extends TransactionBaseService {
protected onboardingRepository_: typeof OnboardingRepository

constructor({ onboardingRepository }: InjectedDependencies) {
super(arguments[0])

this.onboardingRepository_ = onboardingRepository
}

async retrieve(): Promise<OnboardingState | undefined> {
const onboardingRepo = this.activeManager_.withRepository(
this.onboardingRepository_
)

const status = await onboardingRepo.findOne({
where: { id: Not(IsNull()) },
})

return status
}

async update(
data: UpdateOnboardingStateInput
): Promise<OnboardingState> {
return await this.atomicPhase_(
async (transactionManager: EntityManager) => {
const onboardingRepository =
transactionManager.withRepository(
this.onboardingRepository_
)

const status = await this.retrieve()

for (const [key, value] of Object.entries(data)) {
status[key] = value
}

return await onboardingRepository.save(status)
}
)
}
}

export default OnboardingService

This service class implements two methods retrieve to retrieve the current onboarding state, and update to update the current onboarding state.

You can learn more about services in this documentation.

Create API Routes

The last part of this step is to create the API Routes that you’ll consume in the admin widget. There will be two API Routes: Get Onboarding State and Update Onboarding State.

To add these API Routes, create the file src/api/admin/onboarding/route.ts with the following content:

src/api/admin/onboarding/route.ts
import type { 
MedusaRequest,
MedusaResponse,
} from "@medusajs/medusa"
import { EntityManager } from "typeorm"

import OnboardingService from "../../../services/onboarding"

export async function GET(
req: MedusaRequest,
res: MedusaResponse
) {
const onboardingService: OnboardingService =
req.scope.resolve("onboardingService")

const status = await onboardingService.retrieve()

res.status(200).json({ status })
}

export async function POST(
req: MedusaRequest,
res: MedusaResponse
) {
const onboardingService: OnboardingService =
req.scope.resolve("onboardingService")
const manager: EntityManager = req.scope.resolve("manager")

const status = await manager.transaction(
async (transactionManager) => {
return await onboardingService
.withTransaction(transactionManager)
.update(req.body)
}
)

res.status(200).json({ status })
}

Notice how these API Routes use the OnboardingService's retrieve and update methods to retrieve and update the current onboarding state. They resolve the OnboardingService using the Dependency Container.

You can learn more about API Routes in this documentation.


Step 2: Create Onboarding Widget

In this step, you’ll create the onboarding widget with a general implementation. Some implementation details will be added later in the tutorial.

Create the file src/admin/widgets/onboarding-flow/onboarding-flow.tsx with the following content:

src/admin/widgets/onboarding-flow/onboarding-flow.tsx
import { OrderDetailsWidgetProps, ProductDetailsWidgetProps, WidgetConfig, WidgetProps } from "@medusajs/admin"
import { useAdminCustomPost, useAdminCustomQuery, useMedusa } from "medusa-react"
import React, { useEffect, useState, useMemo, useCallback } from "react"
import { useNavigate, useSearchParams, useLocation } from "react-router-dom"
import { OnboardingState } from "../../../models/onboarding"
import {
AdminOnboardingUpdateStateReq,
OnboardingStateRes,
UpdateOnboardingStateInput,
} from "../../../types/onboarding"
import OrderDetailDefault from "../../components/onboarding-flow/default/orders/order-detail"
import OrdersListDefault from "../../components/onboarding-flow/default/orders/orders-list"
import ProductDetailDefault from "../../components/onboarding-flow/default/products/product-detail"
import ProductsListDefault from "../../components/onboarding-flow/default/products/products-list"
import { Button, Container, Heading, Text, clx } from "@medusajs/ui"
import Accordion from "../../components/shared/accordion"
import GetStarted from "../../components/shared/icons/get-started"
import { Order, Product } from "@medusajs/medusa"
import ProductsListNextjs from "../../components/onboarding-flow/nextjs/products/products-list"
import ProductDetailNextjs from "../../components/onboarding-flow/nextjs/products/product-detail"
import OrdersListNextjs from "../../components/onboarding-flow/nextjs/orders/orders-list"
import OrderDetailNextjs from "../../components/onboarding-flow/nextjs/orders/order-detail"

type STEP_ID =
| "create_product"
| "preview_product"
| "create_order"
| "setup_finished"
| "create_product_nextjs"
| "preview_product_nextjs"
| "create_order_nextjs"
| "setup_finished_nextjs"

type OnboardingWidgetProps = WidgetProps | ProductDetailsWidgetProps | OrderDetailsWidgetProps

export type StepContentProps = OnboardingWidgetProps & {
onNext?: Function;
isComplete?: boolean;
data?: OnboardingState;
};

type Step = {
id: STEP_ID;
title: string;
component: React.FC<StepContentProps>;
onNext?: Function;
};

const QUERY_KEY = ["onboarding_state"]

const OnboardingFlow = (props: OnboardingWidgetProps) => {
// create custom hooks for custom API Routes
const { data, isLoading } = useAdminCustomQuery<
undefined,
OnboardingStateRes
>("/onboarding", QUERY_KEY)
const { mutate } = useAdminCustomPost<
AdminOnboardingUpdateStateReq,
OnboardingStateRes
>("/onboarding", QUERY_KEY)

const navigate = useNavigate()
const location = useLocation()
// will be used if onboarding step
// is passed as a path parameter
const { client } = useMedusa()

// get current step from custom API Route
const currentStep: STEP_ID | undefined = useMemo(() => {
return data?.status
?.current_step as STEP_ID
}, [data])

// initialize some state
const [openStep, setOpenStep] = useState(currentStep)
const [completed, setCompleted] = useState(false)

// this method is used to move from one step to the next
const setStepComplete = ({
step_id,
extraData,
onComplete,
}: {
step_id: STEP_ID;
extraData?: UpdateOnboardingStateInput;
onComplete?: () => void;
}) => {
const next = steps[findStepIndex(step_id) + 1]
mutate({ current_step: next.id, ...extraData }, {
onSuccess: onComplete,
})
}

// this is useful if you want to change the current step
// using a path parameter. It can only be changed if the passed
// step in the path parameter is the next step.
const [ searchParams ] = useSearchParams()

// the steps are set based on the
// onboarding type
const steps: Step[] = useMemo(() => {
{
switch(process.env.MEDUSA_ADMIN_ONBOARDING_TYPE) {
case "nextjs":
return [
{
id: "create_product_nextjs",
title: "Create Products",
component: ProductsListNextjs,
onNext: (product: Product) => {
setStepComplete({
step_id: "create_product_nextjs",
extraData: { product_id: product.id },
onComplete: () => {
if (!location.pathname.startsWith(`/a/products/${product.id}`)) {
navigate(`/a/products/${product.id}`)
}
},
})
},
},
{
id: "preview_product_nextjs",
title: "Preview Product in your Next.js Storefront",
component: ProductDetailNextjs,
onNext: () => {
setStepComplete({
step_id: "preview_product_nextjs",
onComplete: () => navigate(`/a/orders`),
})
},
},
{
id: "create_order_nextjs",
title: "Create an Order using your Next.js Storefront",
component: OrdersListNextjs,
onNext: (order: Order) => {
setStepComplete({
step_id: "create_order_nextjs",
onComplete: () => {
if (!location.pathname.startsWith(`/a/orders/${order.id}`)) {
navigate(`/a/orders/${order.id}`)
}
},
})
},
},
{
id: "setup_finished_nextjs",
title: "Setup Finished: Continue Building your Ecommerce Store",
component: OrderDetailNextjs,
},
]
default:
return [
{
id: "create_product",
title: "Create Product",
component: ProductsListDefault,
onNext: (product: Product) => {
setStepComplete({
step_id: "create_product",
extraData: { product_id: product.id },
onComplete: () => {
if (!location.pathname.startsWith(`/a/products/${product.id}`)) {
navigate(`/a/products/${product.id}`)
}
},
})
},
},
{
id: "preview_product",
title: "Preview Product",
component: ProductDetailDefault,
onNext: () => {
setStepComplete({
step_id: "preview_product",
onComplete: () => navigate(`/a/orders`),
})
},
},
{
id: "create_order",
title: "Create an Order",
component: OrdersListDefault,
onNext: (order: Order) => {
setStepComplete({
step_id: "create_order",
onComplete: () => {
if (!location.pathname.startsWith(`/a/orders/${order.id}`)) {
navigate(`/a/orders/${order.id}`)
}
},
})
},
},
{
id: "setup_finished",
title: "Setup Finished: Start developing with Medusa",
component: OrderDetailDefault,
},
]
}
}
}, [location.pathname])

// used to retrieve the index of a step by its ID
const findStepIndex = useCallback((step_id: STEP_ID) => {
return steps.findIndex((step) => step.id === step_id)
}, [steps])

// used to check if a step is completed
const isStepComplete = useCallback((step_id: STEP_ID) => {
return findStepIndex(currentStep) > findStepIndex(step_id)
}, [findStepIndex, currentStep])

// this is used to retrieve the data necessary
// to move to the next onboarding step
const getOnboardingParamStepData = useCallback(async (onboardingStep: string, data?: {
orderId?: string,
productId?: string,
}) => {
switch (onboardingStep) {
case "setup_finished_nextjs":
case "setup_finished":
if (!data?.orderId && "order" in props) {
return props.order
}
const orderId = data?.orderId || searchParams.get("order_id")
if (orderId) {
return (await client.admin.orders.retrieve(orderId)).order
}

throw new Error ("Required `order_id` parameter was not passed as a parameter")
case "preview_product_nextjs":
case "preview_product":
if (!data?.productId && "product" in props) {
return props.product
}
const productId = data?.productId || searchParams.get("product_id")
if (productId) {
return (await client.admin.products.retrieve(productId)).product
}

throw new Error ("Required `product_id` parameter was not passed as a parameter")
default:
return undefined
}
}, [searchParams, props])

const isProductCreateStep = useMemo(() => {
return currentStep === "create_product" ||
currentStep === "create_product_nextjs"
}, [currentStep])

const isOrderCreateStep = useMemo(() => {
return currentStep === "create_order" ||
currentStep === "create_order_nextjs"
}, [currentStep])

// used to change the open step when the current
// step is retrieved from custom API Routes
useEffect(() => {
setOpenStep(currentStep)

if (findStepIndex(currentStep) === steps.length - 1) {setCompleted(true)}
}, [currentStep, findStepIndex])

// used to check if the user created a product and has entered its details page
// the step is changed to the next one
useEffect(() => {
if (location.pathname.startsWith("/a/products/prod_") && isProductCreateStep && "product" in props) {
// change to the preview product step
const currentStepIndex = findStepIndex(currentStep)
steps[currentStepIndex].onNext?.(props.product)
}
}, [location.pathname, isProductCreateStep])

// used to check if the user created an order and has entered its details page
// the step is changed to the next one.
useEffect(() => {
if (location.pathname.startsWith("/a/orders/order_") && isOrderCreateStep && "order" in props) {
// change to the preview product step
const currentStepIndex = findStepIndex(currentStep)
steps[currentStepIndex].onNext?.(props.order)
}
}, [location.pathname, isOrderCreateStep])

// used to check if the `onboarding_step` path
// parameter is passed and, if so, moves to that step
// only if it's the next step and its necessary data is passed
useEffect(() => {
const onboardingStep = searchParams.get("onboarding_step") as STEP_ID
const onboardingStepIndex = findStepIndex(onboardingStep)
if (onboardingStep && onboardingStepIndex !== -1 && onboardingStep !== openStep) {
// change current step to the onboarding step
const openStepIndex = findStepIndex(openStep)

if (onboardingStepIndex !== openStepIndex + 1) {
// can only go forward one step
return
}

// retrieve necessary data and trigger the next function
getOnboardingParamStepData(onboardingStep)
.then((data) => {
steps[openStepIndex].onNext?.(data)
})
.catch((e) => console.error(e))
}
}, [searchParams, openStep, getOnboardingParamStepData])

if (
!isLoading &&
data?.status?.is_complete &&
!localStorage.getItem("override_onboarding_finish")
)
{return null}

// a method that will be triggered when
// the setup is started
const onStart = () => {
mutate({ current_step: steps[0].id })
navigate(`/a/products`)
}

// a method that will be triggered when
// the setup is completed
const onComplete = () => {
setCompleted(true)
}

// a method that will be triggered when
// the setup is closed
const onHide = () => {
mutate({ is_complete: true })
}

// used to get text for get started header
const getStartedText = () => {
switch(process.env.MEDUSA_ADMIN_ONBOARDING_TYPE) {
case "nextjs":
return "Learn the basics of Medusa by creating your first order using the Next.js storefront."
default:
return "Learn the basics of Medusa by creating your first order."
}
}

return (
<>
<Container className={clx(
"text-ui-fg-subtle px-0 pt-0 pb-4",
{
"mb-4": completed,
}
)}>
<Accordion
type="single"
value={openStep}
onValueChange={(value) => setOpenStep(value as STEP_ID)}
>
<div className={clx(
"flex py-6 px-8",
{
"items-start": completed,
"items-center": !completed,
}
)}>
<div className="w-12 h-12 p-1 flex justify-center items-center rounded-full bg-ui-bg-base shadow-elevation-card-rest mr-4">
<GetStarted />
</div>
{!completed ? (
<>
<div>
<Heading level="h1" className="text-ui-fg-base">Get started</Heading>
<Text>
{getStartedText()}
</Text>
</div>
<div className="ml-auto flex items-start gap-2">
{!!currentStep ? (
<>
{currentStep === steps[steps.length - 1].id ? (
<Button
variant="primary"
size="base"
onClick={() => onComplete()}
>
Complete Setup
</Button>
) : (
<Button
variant="secondary"
size="base"
onClick={() => onHide()}
>
Cancel Setup
</Button>
)}
</>
) : (
<>
<Button
variant="secondary"
size="base"
onClick={() => onHide()}
>
Close
</Button>
<Button
variant="primary"
size="base"
onClick={() => onStart()}
>
Begin setup
</Button>
</>
)}
</div>
</>
) : (
<>
<div>
<Heading level="h1" className="text-ui-fg-base">
Thank you for completing the setup guide!
</Heading>
<Text>
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/admin/onboarding?ref=onboarding"
target="_blank"
className="text-blue-500 font-semibold"
>
our guide
</a>
.
</Text>
</div>
<div className="ml-auto flex items-start gap-2">
<Button
variant="secondary"
size="base"
onClick={() => onHide()}
>
Close
</Button>
</div>
</>
)}
</div>
{
<div>
{(!completed ? steps : steps.slice(-1)).map((step) => {
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={step.id}
{...(!isComplete &&
!isCurrent && {
customTrigger: <></>,
})}
>
<div className="pl-14 pb-6 pr-7">
<step.component
onNext={step.onNext}
isComplete={isComplete}
data={data?.status}
{...props}
/>
</div>
</Accordion.Item>
)
})}
</div>
}
</Accordion>
</Container>
</>
)
}

export const config: WidgetConfig = {
zone: [
"product.list.before",
"product.details.before",
"order.list.before",
"order.details.before",
],
}

export default OnboardingFlow

Notice that you'll see errors related to components not being defined. You'll create these components in upcoming sections.

There are three important details to ensure that Medusa reads this file as a widget:

  1. The file is placed under the src/admin/widget directory.
  2. The file exports a config object of type WidgetConfig, which is imported from @medusajs/admin.
  3. The file default exports a React component, which in this case is OnboardingFlow

The extension uses react-router-dom, which is available as a dependency of the @medusajs/admin package, to navigate to other pages in the dashboard.

The OnboardingFlow 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.

To use the custom API Routes created in a previous step, you use the useAdminCustomQuery and useAdminCustomPost hooks from the medusa-react package. You can learn more about these hooks in the Medusa React documentation.

You can learn more about Admin Widgets in this documentation.


Step 3: Create Step Components

In this section, you’ll create the components for each step in the onboarding flow. You’ll then update the OnboardingFlow widget to use these components.

Notice that as there are two types of flows, you'll be creating the components for the default flow and for the Next.js flow.

ProductsListDefault component
ProductDetailDefault component
OrdersListDefault component
OrderDetailDefault component
ProductsListNextjs component
ProductDetailNextjs component
OrdersListNextjs component
OrderDetailNextjs component

Step 4: Test it Out

You’ve now implemented everything necessary for the onboarding flow! You can test it out by building the changes and running the develop command:

npm run build
npx medusa develop

If you open the admin at localhost:7001 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!


Next Steps: Continue Development

Was this section helpful?