DEV Community

Cover image for How I Use Medusa to Create a Powerful Next.js E-commerce Store
Ankur Tyagi
Ankur Tyagi

Posted on • Originally published at theankurtyagi.com

How I Use Medusa to Create a Powerful Next.js E-commerce Store

Introduction

Image description

Medusa is an open-source modular commerce infrastructure for building highly performant, scalable, and data-intensive commerce applications. Medusa’s modular architecture makes it flexible and extensible. It is a great tool for building e-commerce stores. Medusa provides three (3) building blocks for a commerce system: the backend, an admin dashboard, and a storefront.

Next.js is an open-source React framework for building web applications. Next.js pre-rendering allows your application to be incredibly fast.

In this article, I will share how I built an e-commerce store with Medusa and Next.js. At the end of this article, you will have a solid understanding of how to set up a Medusa backend and admin panel and build your storefront from scratch with Next.js.


Prerequisite

To follow this tutorial, you need:

  • Node version 14+ or 16+ on your machine.
  • Either npm, yarn, or pnpm. This tutorial will npm.
  • Familiarity with JavaScript and Next.js

Setting up the Medusa Backend and an Admin Dashboard

Run the following command in your terminal:

npx create-medusa-app

Give your project a name - for this article, you can name it my-store. Choose the medusa-starter-default. For the storefront starter, select None. You are going to build a storefront from scratch with Next.js.

Install the admin dashboard in your Medusa backend. You can deploy your Medusa backend and your admin dashboard together later.

cd my-store/backend
npm install @medusajs/admin

Enter fullscreen mode Exit fullscreen mode

After installing, enable the admin inside medusa-config.js by uncommenting the following lines of code inside the plugins array.

medusa-config.js

const plugins = [
  //...
  {
    resolve: "@medusajs/admin",
    /** @type {import('@medusajs/admin').PluginOptions} */
    options: {
      autoRebuild: true,
    },
  },
];

Enter fullscreen mode Exit fullscreen mode

Start your Medusa server:

cd my-store/backend
npm run start

Enter fullscreen mode Exit fullscreen mode

Your server should be running on port 9000.

Run localhost:9000/store/products inside an API testing platform like Postman or Thunder Client to see the store data populated by the medusa-starter-default.

Image description

The admin dashboard is available at localhost:9000/app.

Image description

Since you do not have a personal login credential yet, use the demo user credentials provided by Medusa.

Enter admin@medusa-test.com in the email field and supersecret as the password.

After logging in, you can create new and private login credentials (and remove the demo credentials). Take a look at the products in the admin dashboard.

Image description


Building the Storefront with Next.js

Medusa provides a Next.js starter storefront that you can use. However, the goal of this article is to show you how you can build your storefront from scratch using Next.js.

Create a Next.js app

npx create-next-app@latest medusa-store-client

Enter fullscreen mode Exit fullscreen mode

Select your preferred configurations. This tutorial will not make use of TypeScript.

Image description

Navigate into your Next.js project, and install a few dependencies that you will need.

cd medusa-store-client
npm install @medusajs/medusa-js styled-components

Enter fullscreen mode Exit fullscreen mode

Use the Medusa JS Client to interact with the Medusa server and styled-components, which is a CSS-in-JS library, to style our components.


Connecting the Storefront to the Medusa Server

By default, Medusa is configured to allow requests from port 8000. However, you can change the default port on the Medusa server. By default, Next.js runs on port 3000. Re-configure it to run on port 8000.

package.json

"scripts": {
    "dev": "next dev -p 8000",
}

Enter fullscreen mode Exit fullscreen mode

The above code changes the development port to 8000.

Create a Medusa JS Client utility that will be used to make requests to the Medusa server.

Create a file named medusaClient.js at the root of your project.

medusaClient.js

import Medusa from "@medusajs/medusa-js"

const medusaClient = new Medusa({baseUrl: process.env.NEXT_PUBLIC_MEDUSA_SERVER_URL })

export {medusaClient}

Enter fullscreen mode Exit fullscreen mode

Put the NEXT_PUBLIC_MEDUSA_SERVER_URL in a .env.local file.

.env.local

 NEXT_PUBLIC_MEDUSA_SERVER_URL="http://localhost:9000"

Enter fullscreen mode Exit fullscreen mode

Configuring Styled Components and Images

To protect your application from malicious users, Next requires configuration to use external images, and the compiler also needs to be notified of the use of styled-components. Configure styled-components and the URL to our product images inside next.config.js.

next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  compiler: {
    styledComponents: true
  },
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: ['medusa-public-images.s3.eu-west-1.amazonaws.com'],
        port: '',

      },
    ],
  },

}

module.exports = nextConfig

Enter fullscreen mode Exit fullscreen mode

Creating the Navbar

Create a Navbar for your store inside the components directory and style it with styled-components.

components/Navbar.js

import Link from 'next/link';
import styled from 'styled-components';
import { ButtonContainer } from './Button';

function Navbar() {

    return (
        <Container>

           <div className="navwrapper">
                <Link href="/">
                    <h2>Ankur's store</h2>
                </Link>
                <Link href={'/components/Cart'}>
                    <ButtonContainer>
                        My Cart 
                    </ButtonContainer>
                </Link>


            </div> 

    </Container>
    )
}

export default Navbar

const Container = styled.div`
    margin-bottom: 4rem;
    .navwrapper{
        display: flex;
        justify-content: space-between;
        padding: .5rem 1rem;
        background: rgb(100,56,90);
        height: 4rem;
        position: fixed;
        top: 0;
        width: 100%;
        z-index: 2;
    }
    a{
        text-decoration: none;
   }
    h2{
        text-transform: capitalize;
        color: white;
        font-family: 'Sansita Swashed', cursive;
        font-size: 2rem;
    }

Enter fullscreen mode Exit fullscreen mode

The ButtonContainer component is a custom button component styled with styled-components. You will be re-using it inside other components.

You already imported it inside Navbar.js, go ahead and create it.

components/Button.js

import styled from 'styled-components';

export const ButtonContainer = styled.button`
    height: 2.8rem;
    width: 6rem;
    font-size: 1.2rem;
    background: rgb(95, 56, 95);
    border: 1px solid;
    color: #fff;
    border-radius: 20px 4px;
    outline: none;
    &:hover{
        background: rgb(90,42,70);
    }

Enter fullscreen mode Exit fullscreen mode

Go ahead and add the Navbar component to the index page.

index.js

import Head from 'next/head'
import Navbar from './components/Navbar'
export default function Home() {
  return (
    <div>
      <Head>
        <title>Create Next App</title>
        <meta name="description" content="Generated by create next app" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <Navbar />


    </div>
  )
}

Enter fullscreen mode Exit fullscreen mode

This is how the Navbar looks.

Image description


Creating the Home page

The Home page will display the products from your Medusa backend.

components/ProductMenu.js

import {useEffect, useState} from 'react'
import Product from './Product'
import { medusaClient } from '../medusaclient';

export default function Productmenu() {

    const [allProducts, setAllProducts]= useState([])


    useEffect(() => {
 const product = async () => {
            const response = await medusaClient.products.list();
            setAllProducts(response.products)
        }

        product()
    },[])


    return (
        <div
            className="change-display"
        >


                  {allProducts.map(product =>(


                    <Product key={product.id} product={product} />


                   ))}

        </div>
    )
}

Enter fullscreen mode Exit fullscreen mode

medusaClient.products.list() retrieves all the products from the server. Now, create the Product page where the products will be listed.

components/Product.js

import React from 'react';
import Link from 'next/link'
import Image from 'next/image';
import styled from 'styled-components';

export default function Product({product}) {

    const {id,title, thumbnail, variants} = product;
 return (

            <ProductWrapper> 
                            <div className="display-flex">
                                <div style={{marginTop: '6rem'}}></div>
                            <div className="products">
                                    <div className="product-name">
                                        <h2>{title}</h2>
                                    </div>

                                <Link href={`/components/${id}`}>
                                    <div className="image-container">
                                        <Image src={thumbnail} alt={title} 
                                        width={200} height={200}/>
                                    </div>

                                </Link>

                                <div className="price-cart-container">

                                    <h3> $<strong>{variants[0]?.prices[1]?.amount}</strong></h3>

                                </div>
                            </div>
                            </div> 
            </ProductWrapper>

    )
}

const ProductWrapper = styled.div`

  display: flex;
  justify-content: center;
  padding-bottom: 2rem;

    .products{
        box-shadow: .5px 1px 2px 1.5px  grey;
        width: 21rem;
        transition: 1s all ease;
    }
    .image-container{
 transition: 1s all ease;
        img{
            width: 20rem;
            transition: 1s all ease;
            padding: .6rem;
        }
        text-align: center;
        &:hover img{
            transform: scale(1.1);
            /* padding: .5rem 0; */
        }
    }
  .product-name{
    background: rgb(214, 202, 202);
    text-align: center;
    font-family: 'Montserrat Alternates', sans-serif;
    height: 2.4rem;
  }
  .price-cart-container{
      display: flex;
      justify-content: space-between;

      h3{
          font-style: italic;
          font-size: 1.7rem;
      }
  }

`

Enter fullscreen mode Exit fullscreen mode

Add the ProductMenu component to the index.js file.

index.js

import Head from 'next/head'
import Navbar from './components/Navbar'
import Productmenu from './components/ProductMenu'

export default function Home() {
  return (
    <div>
     <Head>
        <title>My store</title>
        <meta name="description" content="Generated by create next app" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <Navbar />
      <Productmenu />

    </div>
  )
}

Enter fullscreen mode Exit fullscreen mode

Our homepage looks like this

Image description

When someone clicks on any product, you want it to take them to a details page where they can see the description and other information about the product.

Use dynamic routing to do this. Next.js dynamic routing allows you to add custom parameters to your URL and access individual data efficiently. You also want the detail page to be statically generated (generated at build time). Generating the page at build time will make the storefront performant.

components/[detail].js

import { useState, useEffect } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import styled from "styled-components";
import { medusaClient } from '../medusaclient';
import Navbar from './Navbar';

export async function getStaticPaths(){
    const res = await medusaClient.products.list()
    const paths = res.products.map(product => ({
        params: {detail: product.id.toString()}
    }))

    return {
        paths,
        fallback: false
    }
}

export async function getStaticProps({params}){
    const id = params.detail
    const res = await fetch(`http://localhost:9000/store/products/${id}`)
    const data = await res.json()

    return {
        props: {
            data
        },
        revalidate: 3600
    }
}

const Detail = ({data}) => {

    const {title, thumbnail, description, variants} = data.product

    return (
        <div>
            <Navbar />

        <DetailContainer className="container" >
                             <div className="company">
                                 <h2>{title}</h2>
                             </div>
                             <div className="image-container">
                                 <Image src={thumbnail} alt={title} 
                                        width={200} height={200}/>
                             </div>
                             <div className="price">
                                <h2><strong>Price: </strong>${variants[0]?.prices[1]?.amount}</h2>

                             </div>
                             <div className="description">
                                 <strong>Product description: </strong>
                                 <p className="product-info">
                                     {description}
                                 </p>
                             </div>
                             <div className="buttons">
                                <Link href="/">
                                    <button type="button" className="back-to-product">
                                        Back to products
                                    </button>
                                </Link> 
                                <button type="button" className="add-to-cart">

                                    add to cart
                                    </button>
                             </div>
                         </DetailContainer>
                    </div>
                     )
    }

export default Detail;

const DetailContainer = styled.div`
    margin-top: 8rem;
    display: flex;
    flex-direction: column;
    align-items: center;
    div{
        text-align: center;
    }
    .company h2{
        text-transform: capitalize;
        font-family: cursive;
        font-size: 2.1rem;
        font-weight: 400;
        color: #474747;

    }
    .description{
            padding-top: 1.2rem;
            p{
                font-size: 1.2rem;
            }
        strong{
            font-size: 1.4rem;
            color: #524e4f
        }
    }
    .price{
        text-align: justify;
        strong{
            color: #524e4f
        }
    }
    .buttons{
        display: block;
        margin-top: 1.4rem;
    }
    .back-to-product{
        margin-right: 2rem;
        padding: .4rem .8rem;
        border-radius: 40px;
        text-transform: capitalize;
        background: transparent;
        font-family: sans-serif;
        font-size: 1.3rem;
        color: #048286;
        border: 2px solid #048286;
        outline: none;
        &:hover{
            background: #048286;
            color: white;
        }
    }
    .add-to-cart{
        margin-right: 2rem;
        padding: .4rem .8rem;
        border-radius: 40px;
        text-transform: capitalize;
        background: transparent;
        font-family: sans-serif;
        font-size: 1.3rem;
        color: #8b7b44;
        border: 2px solid #5f8604;
        outline: none;
        &:hover{
            background: #f3c52c;
            color: black;
        }
    }

`
Enter fullscreen mode Exit fullscreen mode

Next, generate each static path as a standalone page at build time.

Click on any product to see its details.

Image description


Building the Cart Functionality

To build the cart functionality, you will check if cart_id exists. If it exists, the selected item will be added to the cart using medusaClient.carts.lineItems.create(), but if it doesn’t, create a new cart and store the id inside localStorage.

Add a modal that pops up a dialogue whenever an item is added to the cart.

components/[details].js

import { useState, useEffect } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import styled from "styled-components";
import { medusaClient } from '../medusaclient';
import Navbar from './Navbar';

const addItem = async (cartId, product) => {
    const {cart} = await medusaClient.carts.lineItems.create(cartId, {
        variant_id: product.variants[0].id,
        quantity: 1,

    })
    localStorage.setItem('cartCount', cart.items.length)
    console.log(cart)

}

export async function getStaticPaths(){
    const res = await medusaClient.products.list()
    const paths = res.products.map(product => ({
        params: {detail: product.id.toString()}
    }))

    return {
        paths,
        fallback: false
    }
}

export async function getStaticProps({params}){
    const id = params.detail
    const res = await fetch(`http://localhost:9000/store/products/${id}`)
    const data = await res.json()

    return {
        props: {
            data
        },
        revalidate: 3600
    }
}

const Detail = ({data}) => {
    const [getRegionId, setGetRegionId] = useState("");
    const [modalState, setModalState] = useState(false) 
    const {title, thumbnail, description, variants} = data.product

    useEffect(() => {
        const region = async () => {
            const res = await medusaClient.regions.list();
            setGetRegionId(res.regions[1].id)
        }

        region()
    }, [])

    const addToCart = async () => {
        const cartId = localStorage.getItem('cart_id');
        setModalState(true)
        if(cartId) {
            addItem(cartId, data.product)
        }else{
            const {cart} = await medusaClient.carts.create({region_id: getRegionId})
            localStorage.setItem('cart_id', cart.id)
            addItem(cart.id, data.product)
            console.log(cart)
        }

    }
    return (
        <div>
            <Navbar />
            <div className="modal" style={{display: modalState ? 'block' : 'none'}}>
                <div className='modal-text'>
                    <h3>Item added to cart</h3>
                    <button onClick={() => setModalState(false)}>Ok</button>

                </div>
            </div>

        <DetailContainer className="container" >
                             <div className="company">
                                 <h2>{title}</h2>
                             </div>
                             <div className="image-container">
                                 <Image src={thumbnail} alt={title} 
                                        width={200} height={200}/>
                             </div>
                             <div className="price">
                                <h2><strong>Price: </strong>${variants[0]?.prices[1]?.amount}</h2>

                             </div>
                             <div className="description">
                                 <strong>Product description: </strong>
                                 <p className="product-info">
                                     {description}
                                 </p>
                             </div>
                             <div className="buttons">
                                <Link href="/">
                                    <button type="button" className="back-to-product">
                                        Back to products
                                    </button>
                                </Link> 
                                <button type="button" className="add-to-cart"
                                    onClick={addToCart}
                                    >

                                    add to cart
                                    </button>
                             </div>
                         </DetailContainer>
                    </div>
                     )
    }

export default Detail;

const DetailContainer = styled.div`
    margin-top: 8rem;
    display: flex;
    flex-direction: column;
    align-items: center;
    div{
        text-align: center;
    }
    .company h2{
        text-transform: capitalize;
        font-family: cursive;
        font-size: 2.1rem;
        font-weight: 400;
        color: #474747;

    }
    .description{
            padding-top: 1.2rem;
            p{
                font-size: 1.2rem;
            }
        strong{
            font-size: 1.4rem;
            color: #524e4f
        }
    }
    .price{
        text-align: justify;
        strong{
            color: #524e4f
        }
    }
    .buttons{
        display: block;
        margin-top: 1.4rem;
    }
    .back-to-product{
        margin-right: 2rem;
        padding: .4rem .8rem;
        border-radius: 40px;
        text-transform: capitalize;
        background: transparent;
        font-family: sans-serif;
        font-size: 1.3rem;
        color: #048286;
        border: 2px solid #048286;
        outline: none;
        &:hover{
            background: #048286;
            color: white;
        }
    }
    .add-to-cart{
        margin-right: 2rem;
        padding: .4rem .8rem;
        border-radius: 40px;
        text-transform: capitalize;
        background: transparent;
        font-family: sans-serif;
        font-size: 1.3rem;
        color: #8b7b44;
        border: 2px solid #5f8604;
        outline: none;
        &:hover{
            background: #f3c52c;
            color: black;
        }
    }

`

Enter fullscreen mode Exit fullscreen mode

The quantity was set to one for the purpose of this tutorial. However, the value of quantity should be dynamic - based on the customer’s desired quantity.

Image description


Building the Cart Page

The cart page will show all the items that are in the cart. Retrieve the cart items by passing cart_id stored inside localStorage to medusaClient.carts.retrieve().

components/Cart.js

import React, {useState, useEffect} from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { ButtonContainer } from './Button';
import { medusaClient } from '../medusaclient';

export default function Cart() {
    const [cart, setCart] = useState([])
    const id = localStorage.getItem("cart_id");


    useEffect(() =>{
        async function getCart(){

            if(id) {
            await medusaClient.carts.retrieve(id)
                .then(({cart}) => setCart(cart.items))

            }
        }
        getCart()

    },[])

    return(

        <div className="cart-flex">
            <h1>My Cart</h1>

            {cart.map(item => {
                console.log(item)
                const {title, thumbnail, subtotal, quantity, id, unit_price} = item
                return(
                    <div key={id} className="cart-div">

                        <p>{title}</p>
                        <Image src={thumbnail} alt={title} 
                        width={20} height={20}/>
                        <p> unit: {quantity}</p>
                        <p>price: ${unit_price}</p>
                        <p>subtotal: ${subtotal}</p>


                    </div>
                )
            })}
            <div className='return'>
                <Link href="/">
                    <ButtonContainer>Go home</ButtonContainer>

                </Link>
            </div>
        </div>
    )
}

Enter fullscreen mode Exit fullscreen mode

Test out the cart. Add a few items to the cart and go to the cart page to view the items.

Image description


Conclusion

In this article, you have learned how to build a commerce application from scratch using Medusa and Next.js. Medusa JS Client was used to interact with the medusa server.

Medusa is a powerful modular commerce infrastructure for building performant and scalable ecommerce stores.

You can extend this project:


That was it for this blog. I hope you learned something new today.

If you did, please like/share so that it reaches others as well.

If you’re a regular reader, thank you, you’re a big part of the reason I’ve been able to share my life/career experiences with you.

Follow Medusa on Twitter for the latest updates and If you found this blog post helpful or interesting, please consider giving Medusa project a star on GitHub.

If you would like to learn more about Medusa. Read my old blog posts here.

1- Everything About Medusa - An Open-Source Alternative to Shopify

2- Shopify vs. Medusa

If you liked this article, you'll like my 2-1-1 Career Growth Newsletter and my Tweets

Top comments (0)