DEV Community

Cover image for Recreating Nike - Medusa & Next.js
Stefan
Stefan

Posted on

Recreating Nike - Medusa & Next.js

Introduction

GitHub Repository

All the code presented in this article is accessible here

What is Medusa?

Medusa is the #1 Open Source headless commerce platform on GitHub. It uses Node.js, and its architecture makes it easy to incorporate Medusa with any tech stack to build cross-platform eCommerce stores.

What is Next.js?

Next.js is an Open Source React framework. It adds several extra features, including server-side rendering.
Next.js is used by many large websites, including Netflix, Starbucks, Twitch, and Nike.

Prerequisites

Before you start, make sure your Node.js version is up-to-date.

Create a backend using Medusa CLI

Install the Medusa CLI

Medusa CLI can be installed by using either npm or yarn. This tutorial uses npm.

npm install @medusajs/medusa-cli -g
Enter fullscreen mode Exit fullscreen mode

Create a new Medusa server

medusa new my-medusa-store --seed
Enter fullscreen mode Exit fullscreen mode

The --seed parameter fills the database with a default user and default products.
my-medusa-store is your store's name; you may replace it with your preferred name.

If the setup was successful, the following should output in your console:

info: Your new Medusa project is ready for you! To start developing run:

cd my-medusa-store
medusa develop
Enter fullscreen mode Exit fullscreen mode

Connect the Admin panel to the Medusa Server

Photo of the Admin panel

The Medusa Admin panel makes it easy to manage your store by providing a way to manage all of your products, discounts, customers, and administrator users in one place.

Clone the Medusa Admin repository:

git clone https://github.com/medusajs/admin medusa-admin
cd medusa-admin
Enter fullscreen mode Exit fullscreen mode

Run the command below to install all dependencies:

npm install
Enter fullscreen mode Exit fullscreen mode

Start it

npm start
Enter fullscreen mode Exit fullscreen mode

Medusa admin runs on port 7000. Navigate to localhost:7000 using your browser to access your admin panel.
Because you used the --seed parameter, an admin account has been created. The e-mail is admin@medusa-test.com, and the password is supersecret.

Visit this guide to learn more about Medusa's Admin panel.
Medusa Admin docs

Create the pages

In a different directory, create a new Next.js project:

npx create-next-app nike-remake-storefront
Enter fullscreen mode Exit fullscreen mode

You may replace nike-remake-storefront with your store's name.
After installing, open the project in your favorite code editor.

Functionality

When the user first visits the site, a cart is created and saved into their browser's local storage which will be used to handle any products being added.

Next, the user loads the three newest products, and they are loaded in the Newest Arrivals showcase.

When the user clicks on one of the products, they are taken to the product page. The page is being passed an id parameter, which is hidden from the user.

The product page then takes that parameter and gives it to the Gallery and AddToBag components.

Finally, the cart page fetches the user's cart using an API request and gives them to the Bag and Subtotal components:

  • The Bag component takes all the products the user has in their cart and shows them in a list containing the product's name, description, and price.
  • The Subtotal component shows the subtotal of the user's order and prompts them with a button to check out.

Styling

In the styles directory, delete the Home.module.css file.
Replace the globals.css file with this:

html,
body {
  padding: 0;
  margin: 0;
  font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
}

a {
  color: inherit;
  text-decoration: none;
}

* {
  box-sizing: border-box;
}

@media (prefers-color-scheme: dark) {
  html {
    color-scheme: dark;
  }
  body {
    color: black;
    background: white;
  }
}
Enter fullscreen mode Exit fullscreen mode

All styles will be handled in the component files themselves.

Create the index page

In the root directory, create a components directory. Next, create a Navbar.js file with the following contents:

import Image from 'next/image'
import Link from "next/link";
import {useEffect, useState} from 'react';

export default function Navbar() {
    const [getCartLength, setCartLength] = useState(0)

    useEffect(() => {
        let id = localStorage.getItem("cart_id");

        if (id) {
            fetch(`http://localhost:9000/store/carts/${id}`, {
                credentials: "include",
            })
                .then((response) => response.json())
                .then(({cart}) => setCartLength(cart.items.length))
                .catch(() => {
                    fetch(`http://localhost:9000/store/carts`, {
                        method: "POST",
                        credentials: "include",
                    })
                        .then((response) => response.json())
                        .then(({cart}) => {
                            if (!cart)
                            localStorage.setItem("cart_id", cart.id);
                            setCartLength(cart.items.length);
                        })
                })
        }
        if (!id) {
            fetch(`http://localhost:9000/store/carts`, {
                method: "POST",
                credentials: "include",
            })
                .then((response) => response.json())
                .then(({cart}) => {
                    if (!cart)
                    localStorage.setItem("cart_id", cart.id);
                    setCartLength(cart.items.length);
                })
        }
    })

    useEffect(() => {
        let prevScrollpos = window.scrollY;
        window.onscroll = function () {
            let currentScrollPos = window.scrollY;
            if (prevScrollpos > currentScrollPos || 170 > currentScrollPos) {
                window.document.getElementById('navbar').style.top = "0";
            } else {
                window.document.getElementById('navbar').style.top = "-60px";
            }
            prevScrollpos = currentScrollPos;
        };
    }, []);
    return (
        <div id='navbar' style={{
            display: 'flex',
            width: '100%',
            height: '60px',
            background: 'white',
            position: 'fixed',
            top: '0px',
            transition: 'top 0.3s'
        }}>
            <Link style={{position: 'absolute', left: '2.5rem'}} href='/'>
                <Image src='/logo.png' width={60} height={60} alt='Store logo'/>
            </Link>
            <Link href='/cart' style={{
                display: 'flex',
                alignItems: 'center',
                position: 'absolute',
                right: '2.5rem',
                height: '60px'
            }}>
                <p>{getCartLength}</p>
                <Image src='/navbar/cart.svg' width={36} height={36} alt='Cart'/>
            </Link>
            <div style={{
                display: 'flex',
                flexWrap: 'wrap',
                gap: '12px',
                justifyContent: 'center',
                alignItems: 'center',
                width: '100%',
                height: '60px'
            }}>
                <Link href='/' style={{fontSize: '16px', fontWeight: '500', lineHeight: '1.5'}}>Products</Link>
                <Link href='/' style={{fontSize: '16px', fontWeight: '500', lineHeight: '1.5'}}>Shoes</Link>
                <Link href='/' style={{fontSize: '16px', fontWeight: '500', lineHeight: '1.5'}}>Sale</Link>
            </div>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

This component uses useEffect; the script inside it (lines 7-16) will only run client-side, and it checks to see if the user scrolls. If the user scrolls upward, it will show the navigation bar; else, it will hide it.
We also implemented all the cart functionality inside it, since it will be a global component.
Photo of the Navbar

Next, you'll need the Headline element.
Inside the components/Landing folder, create a Headline.js file with the following contents:

import Image from 'next/image'

export default function Headline() {
    return (
        <div>
            <div style={{
                display: 'flex',
                justifyContent: 'center',
                alignItems: 'center',
                flexDirection: 'column',
                width: '100%',
                height: '208px'
            }}>
                <p style={{
                    marginBottom: '0'
                }}>Inspired By A Popular Shoe Store</p>
                <h1 style={{
                    fontWeight: '800',
                    fontSize: '72px',
                    margin: '0',
                    letterSpacing: '-6px',
                    textAlign: 'center',
                    textTransform: 'uppercase'
                }}>Do you like it?</h1>
                <p style={{
                    fontSize: '24px',
                    textAlign: 'center'
                }}>This is a store that you&apos;ll like. This is the store that sells the best shoes. This
                    is <b>THE</b> store.</p>
            </div>
            <Image src='/headline/banner.png' width={1920} height={1080} alt='Banner image'
                   style={{width: '100%', height: 'auto'}}/>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

Photo of the Headline

After that, the user needs somewhere where he can see the newest products. Create a Products.js file in the components/Landing folder with these contents:

import Link from "next/link";

import {useEffect, useState} from "react";

export default function Products() {
    const [products, setProducts] = useState([])
    useEffect(() => {
        fetch("http://localhost:9000/store/products?limit=3")
            .then((response) => response.json())
            .then((data) => {
                setProducts(data.products);
            });
    }, []);
    return (
        <div style={{marginLeft: '3rem', marginRight: '3rem'}}>
            <div>
                <p style={{fontSize: '24px'}}>Newest Arrivals</p>
            </div>
            <div style={{
                display: 'flex',
                flexWrap: 'wrap',
                gap: '0.8rem',
                justifyContent: 'center'
            }}>
                {products.map((product) => (
                    <Link key={product.id} href={`/product?prod=${product.id}`} as={'/'} style={{width: '598px'}}>
                        <div key={product.id} style={{
                            width: '100%',
                            height: 'calc(0.389 * 100vw)',
                            backgroundImage: `url(${product.thumbnail}`,
                            backgroundSize: 'contain',
                            backgroundPosition: 'center center',
                            backgroundRepeat: 'no-repeat'
                        }}/>
                        <p style={{display: 'inline-block', marginBottom: '0'}}>{product.title}</p>
                        <p style={{
                            display: 'inline-block',
                            float: 'right'
                        }}>{product.variants[0].prices[0].currency_code.toUpperCase()} {product.variants[0].prices[0].amount / 100}</p>
                        <p style={{marginTop: '0', width: '80%', color: '#757575'}}>{product.description}</p>
                    </Link>
                ))}
            </div>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

This component uses both useEffect and useState; useState is a hook that allows you to store state variables.
This component also uses .map, which calls a function for every element in an array, in this example the element being one of the three products received from the Medusa server using fetch.
Photo of the Products
Finally, open the pages/index.js file and assemble the front page:

import Head from 'next/head';

import Navbar from '../components/Navbar';
import Products from '../components/Landing/Products';
import Headline from '../components/Landing/Headline';

export default function Home() {
    return (
        <div>
            <Head>
                <title>Nike Remake</title>
                <meta name="description" content="Made using Next & Medusa!"/>
                <link rel="icon" href="/favicon.ico"/>
            </Head>
            <Navbar/>
            <div style={{height: '60px'}}/>
            <Headline/>
            <Products/>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

You now have a complete front page.
Photo of the Front Page

Create the product page

Start by creating the components.
Inside the components/Product directory, create two files:

  • AddToBag.js
  • Gallery.js

AddToBag is self-explanatory: it is the button that adds the selected product to the cart.
Gallery is the image gallery of the product.

Inside the AddToBag.js file, paste this in:

import Router from 'next/router';

export default function AddToBag({cart, id}) {
    return (
        <div onClick={() => {
            fetch(`http://localhost:9000/store/carts/${cart}/line-items`, {
                method: "POST",
                credentials: "include",
                headers: {
                    "Content-Type": "application/json",
                },
                body: JSON.stringify({
                    variant_id: id,
                    quantity: 1,
                }),
            })
                .then(() => Router.push('/'))
        }}
             style={{
                 position: 'absolute',
                 bottom: '0',
                 display: 'flex',
                 justifyContent: 'center',
                 width: '100%',
                 minHeight: '60px',
                 background: 'black',
                 color: 'white',
                 borderRadius: '30px'
             }}>
            <p style={{margin: '1.3276rem'}}>Add to Bag</p>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

This component checks when the button is clicked by using the onClick property and sends a post-request to the server with the cart and variant ID, so the product gets added to the user's cart.

The content of the Gallery.js file is:

import Image from 'next/image'

export default function Gallery({gallery}) {
    return (
        <div style={{
            display: 'grid',
            gridTemplateColumns: 'repeat(auto-fit, minmax(444px, 1fr))',
            justifyContent: 'center',
            width: '50vw'
        }}>
            {
                gallery.map((product) => {
                    return <Image src={product.url} key={product.url} alt='Preview Image' width={4000} height={4000}
                                  style={{width: 'auto', height: '542px'}}/>
                })
            }
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

And finally, create the product page by creating the pages/product.js file with the following contents:

import Head from 'next/head';
import {useRouter} from 'next/router';
import {useEffect, useState} from "react";

import Navbar from '../components/Navbar';
import Gallery from '../components/Product/Gallery';
import AddToBag from '../components/Product/AddToBag';
import Products from '../components/Products';

export default function Product() {
    const router = useRouter()
    const [getProdTitle, setProdTitle] = useState('Placeholder')
    const [getProdDescription, setProdDescription] = useState('Placeholder')
    const [getProdVariant, setProdVariant] = useState(null)
    const [getProdPrice, setProdPrice] = useState('Placeholder')
    const [getProdPriceCurrency, setProdPriceCurrency] = useState('Placeholder')
    const [getProdGallery, setProdGallery] = useState([])
    const [getCartId, setCartId] = useState(null)

    useEffect(() => {
        setCartId(localStorage.getItem('cart_id'))
        if (router.query.prod) {
            fetch(`http://localhost:9000/store/products/${router.query.prod}`, {
                credentials: "include",
            })
                .then((response) => response.json())
                .then((obj) => {
                    if (!obj.product) router.push('/')

                    setProdTitle(obj.product.title.toString());
                    setProdDescription(obj.product.description);
                    setProdVariant(obj.product.variants[0].id);
                    setProdPrice(obj.product.variants[0].prices[0].amount);
                    setProdPriceCurrency(obj.product.variants[0].prices[0].currency_code);
                    setProdGallery(obj.product.images)
                })
        }
    }, [])
    return (
        <div>
            <Head>
                <title>Nike Remake - {getProdTitle}</title>
                <meta name="description" content="Made using Next & Medusa!"/>
                <link rel="icon" href="/favicon.ico"/>
            </Head>
            <Navbar/>
            <div style={{height: '100px'}}/>
            <div style={{display: 'flex', justifyContent: 'center'}}>
                <Gallery gallery={getProdGallery}/>
                <div style={{marginLeft: '48px', position: 'relative'}}>
                    <p style={{fontSize: '28px', marginBottom: '0px'}}>{getProdTitle}</p>
                    <p style={{fontSize: '16px', marginTop: '0px', width: '456px'}}>{getProdDescription}</p>
                    <p>{getProdPriceCurrency.toUpperCase()} {getProdPrice / 100}</p>
                    <AddToBag cart={getCartId} id={getProdVariant}/>
                </div>
            </div>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

Photo of the Product page

Create the cart page

Start by creating the components.
The first component will be the Bag component, which will show you what items you have in your bag.

Create a new directory called Cart in the components folder, and create a new file called Bag.js.

import Image from 'next/image'

export default function Bag({items, currency}) {
    return (
        <div style={{width: '45%'}}>
            <p style={{fontSize: '22px'}}>Bag</p>
            {
                items.map((item) => {
                    return (
                        <div key={item.id} style={{display: 'flex', paddingBottom: '24px', width: '95%'}}>
                            <Image alt={`Image of ${item.title}`} src={item.thumbnail} width={256} height={256}
                                   style={{width: 'auto', height: '150px'}}/>
                            <div style={{paddingLeft: '16px', width: '100%'}}>
                                <p style={{fontSize: '16px', display: 'inline-block'}}>{item.title}</p>
                                <p style={{
                                    display: 'inline-block',
                                    float: 'right'
                                }}>{currency.toUpperCase()} {item.unit_price / 100}</p>
                                <p style={{color: 'rgb(117, 117, 117)'}}>{item.variant.product.description}</p>
                                <p style={{color: 'rgb(117, 117, 117)'}}>{item.description}</p>
                                <div style={{width: '100%', height: '0.1rem', backgroundColor: '#E5E5E5'}}/>
                            </div>
                        </div>
                    )
                })
            }
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

Photo of the Bag component

Next, create another file called Subtotal.js, which will host your Subtotal component:

import Router from 'next/router';
export default function Subtotal({subtotal}) {
    return (
        <div style={{width: '17vw', height: '295px', minWidth: '250px'}}>
            <p style={{fontSize: '22px'}}>Summary</p>
            <div>
                <p style={{display: 'inline-block'}}>Subtotal</p>
                <p style={{display: 'inline-block', float: 'right'}}>{subtotal / 100}</p>
            </div>
            <div style={{width: '100%', height: '0.1rem', backgroundColor: '#E5E5E5'}}/>
            <div onClick={() => {
                Router.push('/checkout')
            }}
                 style={{
                     display: 'flex',
                     justifyContent: 'center',
                     width: '100%',
                     minHeight: '60px',
                     marginTop: '1rem',
                     background: 'black',
                     color: 'white',
                     borderRadius: '30px'
                 }}>
                <p style={{margin: '1.3276rem'}}>Checkout</p>
            </div>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

Photo of the Subtotal component

Finally, assemble the page by creating a file named cart.js in the pages directory:

import Head from 'next/head';
import Router from 'next/router';
import {useEffect, useState} from 'react'

import Navbar from '../components/Navbar';
import Bag from '../components/Cart/Bag';
import Subtotal from '../components/Cart/Subtotal';

export default function Cart() {
    const [getCartCurrency, setCartCurrency] = useState('EUR')
    const [getCartSubtotal, setCartSubtotal] = useState('EUR')
    const [getCartItems, setCartItems] = useState([])

    useEffect(() => {
        const cart = localStorage.getItem('cart_id');
        if (cart) {
            fetch(`http://localhost:9000/store/carts/${cart}`, {
                credentials: "include",
            })
                .then((response) => response.json())
                .then(({cart}) => {
                    setCartSubtotal(cart.subtotal)
                    setCartCurrency(cart.region.currency_code)
                    setCartItems(cart.items);
                })
                .catch(() => {
                    Router.push('/')
                })
        }
    })
    return (
        <div>
            <Head>
                <title>Nike Remake - Cart</title>
                <meta name="description" content="Made using Next & Medusa!"/>
                <link rel="icon" href="/favicon.ico"/>
            </Head>
            <Navbar/>
            <div style={{height: '100px'}}/>
            <div style={{display: 'flex', flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center'}}>
                <Bag items={getCartItems} currency={getCartCurrency}/>
                <Subtotal subtotal={getCartSubtotal}/>
            </div>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

Photo of the cart page

Conclusion

In this tutorial, you have successfully created a storefront similar to Nike's.
The storefront only implements the add-to-cart functionality and product listing.
You may need to implement a page that lists products.
Here is a tutorial on implementing the checkout flow.
Here is a blog post about implementing 5 features from Nike's store into Medusa.

If you have any issues or questions related to Medusa, reach out to the Medusa team & community on Discord

Top comments (3)

Collapse
 
nicklasgellner profile image
Nicklas Gellner

Thanks for sharing Stefan. Great piece! ✨

Collapse
 
Sloan, the sloth mascot
Comment deleted
Collapse
 
shahednasser profile image
Shahed Nasser

Awesome article! I look forward to other similar articles