Medusa is an open source headless commerce platform that aims to provide developers with a great developer experience. Although it provides Next.js and Gatsby storefronts to use, you can also use any storefront of your choice.
To make your developer experience even easier, Medusa provides a client NPM package that you can use with JavaScript and Typescript frameworks.
In this tutorial, you’ll learn how to install and use the Medusa JS Client in your storefront to implement a customer sign-up and profile flow.
You can find the code for this tutorial in this GitHub repository.
Prerequisites
This tutorial assumes you already have a Medusa server installed. If not, please follow along the quickstart guide.
Furthermore, this tutorial uses the Next.js starter to implement the customer profile. However, the tutorial will focus on how to use the Medusa JS Client in particular. So, you can still follow along if you are using a different framework for your storefront.
Install the Medusa JS Client
In your storefront project’s directory, install the Medusa JS client if you don’t have it installed already:
npm install @medusajs/medusa-js
If you’re using our Next.js or Gatsby starters, then you don’t need to install it.
Initialize Client
In the Next.js starter, the client is initialized in utils/client.js
and you can just import it into your components. However, if you’re using your custom storefront here’s how you can initialize the Medusa client:
const client = new Medusa({ baseUrl: BACKEND_URL });
Where BACKEND_URL
is the URL to your Medusa server.
You can then use the methods and resources in the client to send all types of requests to your Medusa server which you’ll see later in this tutorial.
Add Styles
This step is optional and can be skipped. To add some styling for the rest of the tutorial, you can create the file styles/customer.module.css
with this content.
Add Customer to Context
In the Next.js starter, the StoreContext
holds all variables and methods important to the store like the cart
object.
In this section, you’ll add a variable and a method to the context: customer
and setCustomer
respectively.
In the defaultStoreContext
variable in context/store-context.js
add the following:
export const defaultStoreContext = {
...,
customer: null,
setCustomer: async () => {}
}
Then, in the reducer
function, add a case for the setCustomer
action:
case "setCustomer":
return {
...state,
customer: action.payload
}
Inside the StoreProvider
function, add inside the useEffect
under the cart initialization the following to retrieve the customer if they are logged in and set it in the context:
//try to retrieve customer
client.auth.getSession()
.then(({customer}) => {
setCustomer(customer)
})
.catch((e) => setCustomer(null))
Notice here that you’re using client.auth.getSession
. client
has been initialized earlier in the file, so if you’re implementing this in your own storefront you’d need to initialize it before this code snippet.
Then, you use auth.getSession
which allows you to retrieve the current logged in customer, if there is any.
If the customer is logged in and is returned in the response, you set it in the context, otherwise you set the customer to null
.
Then, add a new function inside StoreProvider
to handle setting the customer:
const setCustomer = (customer) => {
dispatch({type: 'setCustomer', payload: customer})
}
This will just dispatch the action to the reducer which will set the customer object in the context.
Finally, add the setCustomer
function to the value
prop of StoreContext.Provider
returned in StoreProvider
:
return (
<StoreContext.Provider
value={{
...,
setCustomer
}}
>
{children}
</StoreContext.Provider>
)
Create Sign Up and Sign In Pages
In this section, you’ll create the sign up and sign in pages to allow the customer to either create an account or log in with an existing one.
Sign Up Page
Create the file pages/sign-up.js
with the following content:
import * as Yup from 'yup';
import { useContext, useEffect, useRef } from 'react';
import StoreContext from '../context/store-context';
import { createClient } from "../utils/client"
import styles from "../styles/customer.module.css";
import { useFormik } from "formik";
import { useRouter } from 'next/router';
export default function SignUp() {
const router = useRouter();
const { setCustomer, customer } = useContext(StoreContext)
useEffect(() => {
if (customer) {
router.push("/")
}
}, [customer, router])
const buttonRef = useRef();
const { handleSubmit, handleBlur, handleChange, values, touched, errors } = useFormik({
initialValues: {
email: '',
first_name: '',
last_name: '',
password: ''
},
validationSchema: Yup.object().shape({
email: Yup.string().email().required(),
first_name: Yup.string().required(),
last_name: Yup.string().required(),
password: Yup.string().required()
}),
onSubmit: function (values) {
if (buttonRef.current) {
buttonRef.current.disabled = true;
}
const client = createClient()
client.customers.create({
email: values.email,
first_name: values.first_name,
last_name: values.last_name,
password: values.password
}).then(({ customer }) => {
setCustomer(customer);
})
}
})
return (
<div className={styles.container}>
<main className={styles.main}>
<form onSubmit={handleSubmit} className={styles.signForm}>
<h1>Sign Up</h1>
<div className={styles.inputContainer}>
<label htmlFor="email">Email</label>
<input type="email" name="email" id="email" className={styles.input}
onChange={handleChange} onBlur={handleBlur} value={values.email} />
{errors.email && touched.email && <span className={styles.error}>{errors.email}</span>}
</div>
<div className={styles.inputContainer}>
<label htmlFor="first_name">First Name</label>
<input type="text" name="first_name" id="first_name" className={styles.input}
onChange={handleChange} onBlur={handleBlur} value={values.first_name} />
{errors.first_name && touched.first_name && <span className={styles.error}>{errors.first_name}</span>}
</div>
<div className={styles.inputContainer}>
<label htmlFor="last_name">Last Name</label>
<input type="text" name="last_name" id="last_name" className={styles.input}
onChange={handleChange} onBlur={handleBlur} value={values.last_name} />
{errors.last_name && touched.last_name && <span className={styles.error}>{errors.last_name}</span>}
</div>
<div className={styles.inputContainer}>
<label htmlFor="password">Password</label>
<input type="password" name="password" id="password" className={styles.input}
onChange={handleChange} onBlur={handleBlur} value={values.password} />
{errors.password && touched.password && <span className={styles.error}>{errors.password}</span>}
</div>
<div className={styles.inputContainer}>
<button type="submit" className={styles.btn} ref={buttonRef}>Sign Up</button>
</div>
</form>
</main>
</div>
)
}
In this page, you use Formik and Yup to create and validate the form. This form has 4 fields: email, first name, last name, and password. These fields are required to sign up a customer in Medusa.
The important bit here is the part that uses the Medusa client in the onSubmit
function passed to useFormik
:
const client = createClient()
client.customers.create({
email: values.email,
first_name: values.first_name,
last_name: values.last_name,
password: values.password
}).then(({ customer }) => {
setCustomer(customer);
})
You first initialize the Medusa client. You use the utility function in utils/client.js
to do that, but if you don’t have that in your storefront you can replace it with the initialization mentioned earlier in the tutorial.
Then, you use client.customers.create
which will send a request to the create customer endpoint. This endpoint requires the email
, first_name
, last_name
, and password
fields.
If the sign up is successful, the customer
object will be returned and a session token will be saved in the cookies to keep the user logged in. You use the customer
object to set the customer in the context.
Notice also that at the beginning of the component you check if the customer is already logged in and redirect them to the home page in that case:
useEffect(() => {
if (customer) {
router.push("/")
}
}, [customer, router])
Sign In Page
Next, create the file pages/sign-in.js
with the following content:
import * as Yup from 'yup';
import { useContext, useEffect, useRef } from 'react';
import StoreContext from '../context/store-context';
import { createClient } from "../utils/client"
import styles from "../styles/customer.module.css";
import { useFormik } from "formik";
import { useRouter } from 'next/router';
export default function SignIn() {
const router = useRouter();
const { setCustomer, customer } = useContext(StoreContext)
useEffect(() => {
if (customer) {
router.push("/")
}
}, [customer, router])
const buttonRef = useRef();
const { handleSubmit, handleBlur, handleChange, values, touched, errors } = useFormik({
initialValues: {
email: '',
password: ''
},
validationSchema: Yup.object().shape({
email: Yup.string().email().required(),
password: Yup.string().required()
}),
onSubmit: function (values) {
if (buttonRef.current) {
buttonRef.current.disabled = true;
}
const client = createClient()
client.auth.authenticate({
email: values.email,
password: values.password
}).then(({ customer }) => {
setCustomer(customer);
})
}
})
return (
<div className={styles.container}>
<main className={styles.main}>
<form onSubmit={handleSubmit} className={styles.signForm}>
<h1>Sign In</h1>
<div className={styles.inputContainer}>
<label htmlFor="email">Email</label>
<input type="email" name="email" id="email" className={styles.input}
onChange={handleChange} onBlur={handleBlur} value={values.email} />
{errors.email && touched.email && <span className={styles.error}>{errors.email}</span>}
</div>
<div className={styles.inputContainer}>
<label htmlFor="password">Password</label>
<input type="password" name="password" id="password" className={styles.input}
onChange={handleChange} onBlur={handleBlur} value={values.password} />
{errors.password && touched.password && <span className={styles.error}>{errors.password}</span>}
</div>
<div className={styles.inputContainer}>
<button type="submit" className={styles.btn} ref={buttonRef}>Sign In</button>
</div>
</form>
</main>
</div>
)
}
This page also makes use of Formik and Yup to create and validate the form. This form only needs an email and a password.
The important bit is in the onSubmit
function passed to useFormik
:
const client = createClient()
client.auth.authenticate({
email: values.email,
password: values.password
}).then(({ customer }) => {
setCustomer(customer);
})
Just like before, you start by initializing the Medusa client. Then, you authenticate the customer using client.auth.authenticate
which calls the Authenticate Customer endpoint. This endpoint requires 2 parameters: email
and password
.
If successful, a customer object is returned which you use to set the customer in the store context using setCustomer
. A cookie will also be set to maintain the login session for the customer.
Add Links to Navigation Bar
Finally, in components/layout/nav-bar.jsx
, initialize the customer from the StoreContext
:
const { cart, customer } = useContext(StoreContext)
Then, in the returned JSX add the following to add a link to the new pages:
{!customer && <Link href="/sign-up">Sign Up</Link>}
{!customer && <Link href="/sign-in">Sign In</Link>}
{customer && <Link href="/customer">Profile</Link>}
Notice that you also add a link to the customer profile, which you’ll implement in the next section.
Test it Out
Make sure that the Medusa server is running first. Then, run the server for your storefront:
npm run dev
If you open localhost:8000
now, you’ll see that there are 2 new links in the navigation bar for sign up and sign in.
You can try clicking on Sign Up and registering as a new user.
Or click Sign In and log in as an existing user.
Once you’re logged in, you should be redirected back to the home page and you should see a Profile link in the navigation bar.
Add Customer Profile
The customer profile will have 3 pages: Edit customer info, view orders, and view addresses.
Create Profile Layout
You’ll start by creating a layout that all profile pages will use.
Create components/layout/profile.jsx
with the following content:
import { useContext, useEffect } from 'react';
import Link from 'next/link';
import StoreContext from '../../context/store-context';
import styles from "../../styles/customer.module.css";
import { useRouter } from 'next/router';
export default function Profile ({children, activeLink}) {
const router = useRouter()
const { customer } = useContext(StoreContext)
useEffect(() => {
if (!customer) {
router.push('/')
}
}, [customer, router])
return (
<div className={styles.container}>
<main className={styles.main}>
<div className={styles.profile}>
<div className={styles.profileSidebar}>
<Link href="/customer">
<a className={activeLink === 'customer' ? styles.active : ''}>Edit Profile</a>
</Link>
<Link href="/customer/orders">
<a className={activeLink === 'orders' ? styles.active : ''}>Orders</a>
</Link>
<Link href="/customer/addresses">
<a className={activeLink === 'addresses' ? styles.active : ''}>Addresses</a>
</Link>
</div>
<div className={styles.profileContent}>
{children}
</div>
</div>
</main>
</div>
)
}
This first checks the customer and context to see if the user is logged in. Then, it displays a sidebar with 3 links, and the main content displays the children.
Create Edit Profile Page
The edit profile page will be the main customer profile page. Create the file pages/customer/index.js
with the following content:
import * as Yup from 'yup';
import { useContext, useRef } from 'react';
import Profile from '../../components/layout/profile';
import StoreContext from "../../context/store-context";
import { createClient } from "../../utils/client"
import styles from "../../styles/customer.module.css";
import { useFormik } from 'formik';
export default function CustomerIndex() {
const { customer, setCustomer } = useContext(StoreContext)
const buttonRef = useRef()
const { handleSubmit, handleChange, handleBlur, values, errors, touched } = useFormik({
initialValues: {
email: customer?.email,
first_name: customer?.first_name,
last_name: customer?.last_name,
password: ''
},
validationSchema: Yup.object().shape({
email: Yup.string().email().required(),
first_name: Yup.string().required(),
last_name: Yup.string().required(),
password: Yup.string()
}),
onSubmit: (values) => {
buttonRef.current.disabled = true;
const client = createClient()
if (!values.password) {
delete values['password']
}
client.customers.update(values)
.then(({ customer }) => {
setCustomer(customer)
alert("Account updated successfully")
buttonRef.current.disabled = false;
})
}
})
return (
<Profile activeLink='customer'>
<form onSubmit={handleSubmit}>
<h1>Edit Profile</h1>
<div className={styles.inputContainer}>
<label htmlFor="email">Email</label>
<input type="email" name="email" id="email" className={styles.input}
onChange={handleChange} onBlur={handleBlur} value={values.email} />
{errors.email && touched.email && <span className={styles.error}>{errors.email}</span>}
</div>
<div className={styles.inputContainer}>
<label htmlFor="first_name">First Name</label>
<input type="text" name="first_name" id="first_name" className={styles.input}
onChange={handleChange} onBlur={handleBlur} value={values.first_name} />
{errors.first_name && touched.first_name && <span className={styles.error}>{errors.first_name}</span>}
</div>
<div className={styles.inputContainer}>
<label htmlFor="last_name">Last Name</label>
<input type="text" name="last_name" id="last_name" className={styles.input}
onChange={handleChange} onBlur={handleBlur} value={values.last_name} />
{errors.last_name && touched.last_name && <span className={styles.error}>{errors.last_name}</span>}
</div>
<div className={styles.inputContainer}>
<label htmlFor="password">Password</label>
<input type="password" name="password" id="password" className={styles.input}
onChange={handleChange} onBlur={handleBlur} value={values.password} />
{errors.password && touched.password && <span className={styles.error}>{errors.password}</span>}
</div>
<div className={styles.inputContainer}>
<button type="submit" className={styles.btn} ref={buttonRef}>Save</button>
</div>
</form>
</Profile>
)
}
This is very similar to the sign-up form. You use Formik and Yup to create and validate the form. The form has 4 inputs: email
, first_name
, last_name
, and password
which is optional.
The important bit here is the part in the onSubmit
function passed to useFormik
:
const client = createClient()
if (!values.password) {
delete values['password']
}
client.customers.update(values)
.then(({ customer }) => {
setCustomer(customer)
alert("Account updated successfully")
buttonRef.current.disabled = false;
})
Just like you’ve done before, you start by initializing the Medusa client. Then, if the password is not set, you remove it from the list of values since you’ll be passing it to the server as-is. You should only pass the password if the customer wants to change it.
To update the customer info, you can use client.customers.update
which sends a request to the Update Customer endpoint. This endpoint accepts a few optional parameters including email
, last_name
, first_name
, and password
.
If the customer info is updated successfully, a customer object is returned which you use to set the updated customer in the context. You also show an alert to the customer that their account is now updated.
If you click on the Profile link in the navigation bar now, you’ll see the profile page with the sidebar and the edit profile page as the first page.
Try updating any of the information and click Save. You should then see an alert to let you know that it’s been updated successfully.
Orders Page
The next page you’ll create is an orders page which will display the customer’s orders.
Create a new file pages/customer/orders.js
with the following content:
import { useEffect, useState } from 'react';
import Link from 'next/link';
import Profile from '../../components/layout/profile';
import { createClient } from "../../utils/client"
import { formatMoneyAmount } from '../../utils/prices';
import styles from "../../styles/customer.module.css";
import { useRouter } from 'next/router';
export default function Orders () {
const [orders, setOrders] = useState([])
const [pages, setPages] = useState(0)
const router = useRouter()
const p = router.query.p ? parseInt(router.query.p - 1) : 0
useEffect(() => {
const client = createClient()
client.customers.listOrders({
limit: 20,
offset: 20 * p
}).then((result) => {
setOrders(result.orders)
setPages(Math.ceil(result.count / result.limit))
})
}, [p])
return (
<Profile activeLink='orders'>
<h1>Orders</h1>
<table className={styles.table}>
<thead>
<tr>
<th>ID</th>
<th>Total</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{orders.map((order) => (
<tr key={order.id}>
<td>{order.id}</td>
<td>{formatMoneyAmount({
currencyCode: order.currency_code,
amount: order.total
}, 2)}</td>
<td>{order.status}</td>
</tr>
))}
</tbody>
</table>
<div className={styles.pagination}>
{pages > 0 && p > 1 && (
<Link href={`/customer/orders?p=${p - 1}`}>Previous</Link>
)}
{pages > 1 && p > 0 && p < pages && <span> - </span>}
{pages > 1 && (p + 1) < pages && (
<Link href={`/customer/orders?p=${p + 1}`}>Next</Link>
)}
</div>
</Profile>
)
}
You first create an orders
state variable which starts out as empty. You also have a pages
state variable to keep track of the number of pages available. Then, in useEffect
, you retrieve the orders using the Medusa client:
useEffect(() => {
const client = createClient()
client.customers.listOrders({
limit: 20,
offset: 20 * p
}).then((result) => {
setOrders(result.orders)
setPages(Math.ceil(result.count / result.limit))
})
}, [p])
After initializing the client, you retrieve the orders of the customer using client.customers.listOrders
. This method sends a request to the Retrieve Customer Orders endpoint. This endpoint accepts 4 fields: limit
, offset
, fields
, and expand
. Here, you just use limit
and offset
.
limit
indicates how many orders should be retrieved per page, and offset
indicates how many orders to skip from the beginning to get the orders of the current page.
This request returns the list of orders as well as additional fields important for pagination including count
which indicates the total count of the orders and limit
which is the current limit set.
You set the orders
state variable to the orders returned from the method and you set the number of pages based on the count
and limit
fields.
Finally, you display the orders
in a table showing the ID, total, and status. You also show pagination links for “Previous” and “Next” if applicable for the page. This is done for the simplicity of the tutorial.
If you open the orders page now, you’ll see the list of orders for your customer if they have any.
Addresses Page
The last page you’ll create in the profile is the Addresses page which will allow the customer to see their shipping addresses.
Create the file pages/customer/addresses.js
with the following content:
import Profile from "../../components/layout/profile"
import StoreContext from "../../context/store-context"
import styles from "../../styles/customer.module.css"
import { useContext } from 'react'
export default function Addresses() {
const { customer } = useContext(StoreContext)
return (
<Profile activeLink='addresses'>
<h1>Addresses</h1>
{customer && customer.shipping_addresses.length === 0 && <p>You do not have any addresses</p>}
{customer && customer.shipping_addresses.map((address) => (
<div key={address.id} className={styles.address}>
<span><b>First Name:</b> {address.first_name}</span>
<span><b>Last Name:</b> {address.last_name}</span>
<span><b>Company:</b> {address.company}</span>
<span><b>Address Line 1:</b> {address.address_1}</span>
<span><b>Address Line 2:</b> {address.address_2}</span>
<span><b>City:</b> {address.city}</span>
<span><b>Country:</b> {address.country}</span>
</div>
))}
</Profile>
)
}
You use the shipping_addresses
field in the customer
object stored in the context and display the addresses one after the other. You can also access the billing address if the customer has any using customer.billing_address
.
If you go to the Addresses page now, you’ll see the customer’s shipping addresses listed.
What’s Next
Using the Medusa JS Client, you can easily interact with the APIs to create an even better customer experience in your storefront.
This tutorial did not cover all the aspects of a profile for simplicity. Here’s what else you can implement:
-
View Single Orders: You can use the orders object you already retrieved on the orders page to show the information of a single order or you can use the Retrieve Order endpoint which is accessible in the client under
client.orders.retrieve
. -
Address Management: You can use the Update Shipping Address endpoint accessible in the client under
client.customers.addresses.updateAddress
; you can use the Add Shipping Address endpoint which is accessible in the client underclient.customers.addresses.addAddress
; and you can use the Delete Shipping Address endpoint which is accessible in the client underclient.customers.addresses.deleteAddress
. -
Saved Payment Methods: You can retrieve the saved payment methods if you use a payment provider that supports saving payment methods like Stripe using the Retrieve Saved Payment Methods endpoint accessible in the client under
client.customers.paymentMethods.list
. -
Reset Password: You can add reset password functionality using the Create Reset Password Token endpoint accessible in the client under
client.customers.generatePasswordToken
, then you can reset the password using the Reset Customer Password endpoint accessible in the client underclient.customers.resetPassword
.
You can check out the API documentation for a full list of endpoints to see what more functionalities you can add to your storefront.
Should you have any issues or questions related to Medusa, then feel free to reach out to the Medusa team via Discord.