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
Create a new Medusa server
medusa new my-medusa-store --seed
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
Connect the Admin panel to the Medusa Server
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
Run the command below to install all dependencies:
npm install
Start it
npm start
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.
Create the pages
In a different directory, create a new Next.js project:
npx create-next-app nike-remake-storefront
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 (a cookie 🍪) 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;
}
}
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>
)
}
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.
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'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>
)
}
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>
)
}
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
.
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>
)
}
You now have a complete 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>
)
}
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>
)
}
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>
)
}
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>
)
}
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>
)
}
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>
)
}
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