Recreating Steam - Medusa & Next.js
Introduction
GitHub Repository
All the code presented in this article is accessible here
What is Medusa?
Medusa stands as the leading Open Source headless commerce platform on GitHub, using Node.js. Its architecture allows for seamless integration with any tech stack, enabling the creation of cross-platform eCommerce stores with ease.
What is Next.js?
Next.js is one of, if not the most used React-based framework for building scalable and fast web applications. It offers features such as server-side rendering and optimized performance for quick page loads. Additionally, it also supports statically exported websites, making it a versatile & widely used tool for modern web development.
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 simplifies the management of your store by offering a centralized platform to oversee your products, discounts, customers, and adminis.
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
To access the Medusa Admin, go to localhost:7000
on your browser. The admin panel operates on port 7000. An administrator account has already been generated for you, as you have used the --seed
parameter, with the email address admin@medusa-test.com
and the password supersecret
.
Visit this guide to learn more about Medusa's Admin panel.
Create the storefront
Create the pages
In a different directory, create a new Next.js project:
npx create-next-app steam-remake-storefront
You may replace steam-remake-storefront
with your store's name. After installing, open the project in your favorite code editor.
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: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
}
a {
color: inherit;
text-decoration: none;
}
html {
color-scheme: dark;
}
body {
color: white;
background: #1B2838;
}
Head over to the pages
directory and replace index.js
with this:
import Head from 'next/head'
import {useEffect, useState} from "react";
import Navbar from "../components/Navbar";
import Navbar_store from "../components/Navbar_store"
import BigShowcase from "../components/Landing/BigShowcase";
import BrowseIncentive from "../components/Landing/BrowseIncentive";
import Spotlight from "../components/Landing/Spotlight";
import ProductList from "../components/Landing/ProductList";
export default function Home() {
const [getCart, setCart] = useState(null)
useEffect(() => {
var id = localStorage.getItem("cart_id");
if (id) {
fetch(`http://localhost:9000/store/carts/${id}`, {
credentials: "include",
})
.then((response) => response.json())
.then(({cart}) => setCart(cart.items.length))
}
if (!id) {
fetch(`http://localhost:9000/store/carts`, {
method: "POST",
credentials: "include",
})
.then((response) => response.json())
.then(({cart}) => {
localStorage.setItem("cart_id", cart.id);
setCart(cart.items.length);
})
}
})
return (
<div>
<Head>
<title>Steam Remake</title>
<meta name="description" content="Made using Next & Medusa!"/>
</Head>
<Navbar/>
<Navbar_store items={getCart}/>
<BigShowcase/>
<BrowseIncentive/>
<Spotlight/>
<div style={{background: 'linear-gradient( to bottom, rgba(42,71,94,1.0) 5%, rgba(42,71,94,0.0) 70%)', borderTopLeftRadius: '6px', borderTopRightRadius: '6px'}}>
<ProductList/>
</div>
<div style={{marginBottom: '1%'}}/>
</div>
)
}
You may replace the description
(Made using Next & Medusa
) and the title
(Steam Remake
) with your own store's name and promo. You might see that the code above gives you errors; it is using components that do not exist yet. Likewise, you'll create them later.
Create a new file in the pages
directory called product.js
with these contents:
import Head from 'next/head'
import {useRouter} from "next/router";
import {useEffect, useState} from "react";
import Navbar from "../components/Navbar";
import Navbar_store from "../components/Navbar_store"
import Title from "../components/Product/Title"
import Thumbnail from "../components/Product/Thumbnail"
import AddToCart from "../components/Product/AddToCart";
export default function Product() {
const router = useRouter()
const [getProdTitle, setProdTitle] = useState('Placeholder')
const [getProdDescription, setProdDescription] = useState('Placeholder')
const [getProdVariant, setProdVariant] = useState('Placeholder')
const [getProdPrice, setProdPrice] = useState('Placeholder')
const [getProdPriceCurrency, setProdPriceCurrency] = useState('Placeholder')
const [getProdThumbnail, setProdThumbnail] = useState('')
const [getCartId, setCartId] = useState(null)
const [getCart, setCart] = useState(null)
useEffect(() => {
if (!router.query.id) {
router.push('/404')
}
if (router.query.id) {
fetch(`http://localhost:9000/store/products/${router.query.id}`, {
credentials: "include",
})
.then((response) => response.json())
.then((obj) => {
if (!obj.product) return router.push('/404')
setProdTitle(obj.product.title);
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);
setProdThumbnail(obj.product.thumbnail);
})
var id = localStorage.getItem("cart_id");
if (id) {
fetch(`http://localhost:9000/store/carts/${id}`, {
credentials: "include",
})
.then((response) => response.json())
.then(({cart}) => {
setCart(cart['items']['length'])
setCartId(cart.id)
})
}
if (!id) {
fetch(`http://localhost:9000/store/carts`, {
method: "POST",
credentials: "include",
})
.then((response) => response.json())
.then(({cart}) => {
localStorage.setItem("cart_id", cart['id']);
setCart(cart['items']['length']);
setCartId(cart.id)
})
}
}
}, [router.query.id])
return (
<div>
<Head>
<title>Steam Remake - {getProdTitle}</title>
<meta name="description" content="Made using Next & Medusa!"/>
</Head>
<Navbar/>
<Navbar_store items={getCart}/>
<Title title={getProdTitle} description={getProdDescription}/>
<Thumbnail url={getProdThumbnail}/>
<AddToCart cart={getCartId} id={getProdVariant} name={getProdTitle} price={getProdPrice} currency={getProdPriceCurrency}/>
<div style={{marginBottom: '2%'}}/>
</div>
)
}
If you are hosting the Medusa server on a different machine, or if the Medusa server is using a different port, replace http://localhost:9000/
with the server's address or 9000
with the server's port, respectively.
Create the components
Build the navigation bars
A navigation bar is an element in a website that provides links to access the most important sections of a website. They are an important part of a website's UI (user interface), as they help users quickly find the information they are looking for.
Create a new folder in the root directory called components
with these two files: Navbar.js
import Link from 'next/link'
export default function Navbar() {
return (
<div style={{
display: 'flex',
width: '100%',
height: '100px',
marginBottom: '1.3%',
background: '#171A21',
justifyContent: 'center',
alignItems: 'center'
}}>
<h1 style={{marginRight: '5%'}}>ACME</h1>
<Link style={{textAlign: 'center', marginRight: '1%'}} href="/">STORE</Link>
<Link style={{textAlign: 'center', marginRight: '1%'}} href="/community">COMMUNITY</Link>
<Link style={{textAlign: 'center', marginRight: '1%'}} href="/about">ABOUT</Link>
<Link style={{textAlign: 'center', marginRight: '1%'}} href="/support">SUPPORT</Link>
</div>
)
}
Navbar_store.js
import Link from 'next/link'
export default function Navbar_store({items}) {
return (
<div>
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}}>
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '940px',
gap: '2vw',
height: '35px',
background: 'linear-gradient(90deg, rgba(62, 103, 150, 0.919) 11.38%, rgba(58, 120, 177, 0.8) 25.23%, rgb(15, 33, 110) 100%)',
boxShadow: '0 0 3px rgba( 0, 0, 0, 0.4)',
fontSize: '13px'
}}>
<Link href="/" style={{marginLeft: '2%'}}>Your Store</Link>
<Link href="/" style={{marginLeft: '2%'}}>New & Noteworthy</Link>
<Link href="/" style={{marginLeft: '2%'}}>Points Shop</Link>
<Link href="/" style={{marginLeft: '2%'}}>News</Link>
<Link href="/" style={{marginLeft: '2%'}}>Labs</Link>
<Link href="/cart" style={{
marginLeft: '2%',
backgroundColor: '#718E02',
width: '100px',
height: '20px',
textAlign: 'center'
}}>CART ({items})</Link>
</div>
</div>
</div>
)
}
Build the landing page's components
Now, create a new directory inside the components
folder called Landing
. It should contain 4 files: BigShowcase.js
import Image from 'next/image'
import Link from 'next/link'
export default function BigShowcase() {
return (
<div style={{display: 'flex', justifyContent: 'center', marginTop: '1.5vh'}}>
<div style={{width: '940px', height: '392px'}}>
<h2 style={{
fontSize: '14px',
fontWeight: 'bold',
fontFamily: '"Motiva Sans", Sans-serif',
letterSpacing: '0.04em',
textTransform: 'uppercase'
}}>Featured & Recommended</h2>
<Link href="/product/">
<div style={{
width: '940px',
height: '353px',
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center',
boxShadow: '0 0 7px 0px #000'
}}>
<Image style={{float: 'left', boxShadow: '0px 0px 30px 1px #171A21', zIndex: '1'}}
src="/previewSs/previewBanner.jpg"
alt="Big Showcase Image" width={628} height={353}/>
<div
style={{width: '312px', height: '353px', backgroundImage: 'url(/previewSs/previewBg.jpg)'}}>
<div style={{margin: '0 0 0 2%'}}>
<p style={{fontSize: '24px', marginTop: '5%'}}>medusa-stonkers</p>
<div style={{display: 'flex', flexWrap: 'wrap', gap: '3px'}}>
<Image alt='Preview Image' src="/PreviewSs/1.png" width={150} height={84}/>
<Image alt='Preview Image' src="/PreviewSs/2.png" width={150} height={84}/>
<Image alt='Preview Image' src="/PreviewSs/3.png" width={150} height={84}/>
<Image alt='Preview Image' src="/PreviewSs/4.png" width={150} height={84}/>
<p style={{marginTop: '10%', fontSize: '18px'}}>Now available</p>
</div>
<p style={{marginTop: '10px', fontSize: '11px'}}>$0.00</p>
</div>
</div>
</div>
</Link>
</div>
</div>
)
}
BrowseIncentive.js
import Link from 'next/link'
export default function BrowseIncentive() {
return (
<div style={{display: 'flex', justifyContent: 'center', marginTop: '10px'}}>
<div style={{width: '940px', height: '95px'}}>
<h2 style={{
fontSize: '14px',
fontWeight: 'bold',
fontFamily: '"Motiva Sans", Sans-serif',
letterSpacing: '0.04em',
textTransform: 'uppercase'
}}>Browse ACME</h2>
<div style={{display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '8px'}}>
<Link href='/' style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '58px',
fontFamily: '"Motiva Sans", Sans-serif',
fontSize: '16px',
textTransform: 'uppercase',
fontWeight: '500',
letterSpacing: '0.03em',
textAlign: 'center',
background: 'linear-gradient(90deg, #06BFFF 0%, #2D73FF 100%)',
borderRadius: '3px',
boxShadow: '0 0 4px #000'
}}>New Releases</Link>
<Link href='/' style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '58px',
fontFamily: '"Motiva Sans", Sans-serif',
fontSize: '16px',
textTransform: 'uppercase',
fontWeight: '500',
letterSpacing: '0.03em',
textAlign: 'center',
background: 'linear-gradient(90deg, #06BFFF 0%, #2D73FF 100%)',
borderRadius: '3px',
boxShadow: '0 0 4px #000'
}}>Specials</Link>
<Link href='/' style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '58px',
fontFamily: '"Motiva Sans", Sans-serif',
fontSize: '16px',
textTransform: 'uppercase',
fontWeight: '500',
letterSpacing: '0.03em',
textAlign: 'center',
background: 'linear-gradient(90deg, #06BFFF 0%, #2D73FF 100%)',
borderRadius: '3px',
boxShadow: '0 0 4px #000'
}}>Free Games</Link>
<Link href='/' style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '58px',
fontFamily: '"Motiva Sans", Sans-serif',
fontSize: '16px',
textTransform: 'uppercase',
fontWeight: '500',
letterSpacing: '0.03em',
textAlign: 'center',
background: 'linear-gradient(90deg, #06BFFF 0%, #2D73FF 100%)',
borderRadius: '3px',
boxShadow: '0 0 4px #000'
}}>By User Tags</Link>
</div>
</div>
</div>
)
}
Spotlight.js
import Link from "next/link";
import {useEffect, useState} from "react";
export default function Spotlight() {
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={{display: "flex", justifyContent: "center", marginTop: "10px"}}>
<div style={{width: "940px"}}>
<h2 style={{
fontSize: '14px',
fontWeight: 'bold',
fontFamily: '"Motiva Sans", Sans-serif',
letterSpacing: '0.04em',
textTransform: 'uppercase'
}}>New 🌟</h2>
<div style={{display: "grid", gridTemplateColumns: "repeat(3, 1fr)", justifyContent: 'center', gap: "8px"}}>
{products.map((product) => (
<Link key={product.id} href={`/product?id=${product.id}`}>
<div key={product.id} style={{
height: "350px",
backgroundImage: `url(${product.thumbnail}`,
backgroundSize: 'contain',
backgroundPosition: 'center center'
}}/>
</Link>
))}
</div>
</div>
</div>
);
}
ProductList.js
import Image from 'next/image';
import Link from "next/link";
import {useEffect, useState} from "react";
export default function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch("http://localhost:9000/store/products")
.then((response) => response.json())
.then((data) => {
setProducts(data.products);
});
}, []);
return (
<div style={{display: "flex", justifyContent: "center", marginTop: "10px"}}>
<div style={{width: "940px"}}>
<h2 style={{
fontSize: '14px',
fontWeight: 'bold',
fontFamily: '"Motiva Sans", Sans-serif',
letterSpacing: '0.04em',
textTransform: 'uppercase'
}}>Product List</h2>
<div style={{
display: "flex",
flexDirection: 'column',
gap: "8px"
}}>
{products.map((product) => (
<Link key={product.id} href={`/product?id=${product.id}`}>
<div style={{
display: 'flex',
flexDirection: 'row',
width: '940px',
height: '69px',
position: 'relative',
background: 'rgba(0, 0, 0, 0.2)'
}}>
<Image src={product.thumbnail} width={1080} height={1080}
style={{width: 'auto', height: '69px', paddingRight: '12px'}}/>
<div style={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
position: 'relative',
width: '860px'
}}>
<div>
<p style={{
color: '#C7D5E0',
marginTop: '10px',
marginBottom: '0px',
fontSize: '1.25em',
zIndex: '1',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
overflow: 'hidden',
transition: 'color 0.25s',
}}>{product.title}</p>
<p style={{marginTop: '0px', fontSize: '12px', color: 'gray'}}>{product.description}</p>
</div>
<div style={{position: 'absolute', right: '0'}}>
<p style={{color: '#BEEE11'}}>{product.variants[0].prices[0].amount / 100}{product.variants[0].prices[0].currency_code.toUpperCase()}</p>
</div>
</div>
</div>
</Link>
))}
</div>
</div>
</div>
);
}
Build the products page's components
Create a new folder inside the components
directory called Product
, which will contain 3 files: AddToCart.js
export default function AddToCart({cart, id, name, price, currency}) {
return (
<div style={{display: 'flex', justifyContent: 'center', marginTop: '2%'}}>
<div style={{
display: 'flex',
width: '940px',
height: '73px',
position: 'relative',
background: 'linear-gradient( -60deg, rgba(226,244,255,0.3) 5%,rgba(84, 107, 115, 0.3) 95%)',
alignItems: 'center'
}}>
<p style={{
fontFamily: '"Motiva Sans", Sans-serif',
fontSize: '21px',
marginTop: '0',
marginBottom: '10px',
marginLeft: '1rem'
}}>Buy {name}</p>
<div style={{position: 'absolute', right: '16px', bottom: '-17px', left: '16px', textAlign: 'right'}}>
<div style={{
height: '32px',
verticalAlign: 'bottom',
display: 'inline-flex',
background: 'black',
padding: '2px 2px 2px 0',
borderRadius: '2px'
}}>
<div style={{
background: 'black',
fontSize: '13px',
paddingTop: '8px',
paddingLeft: '12px',
paddingRight: '12px',
textTransform: 'uppercase',
height: '24px'
}}>{price / 100} {currency}</div>
<div style={{
position: 'relative',
fontSize: '12px',
display: 'inline-block',
marginLeft: '2px',
verticalAlign: 'middle'
}}>
<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((response) => console.log(response.json()))
}} style={{
width: '110px',
height: '30px',
borderRadius: '2px',
border: 'none',
padding: '1px',
display: 'inline-flex',
justifyContent: 'center',
alignItems: 'center',
cursor: 'pointer',
color: '#D2EFA9',
background: 'linear-gradient( to right, #8ed629 5%, #6aa621 95%)',
textShadow: '1px 1px 0px rgba( 0, 0, 0, 0.3 )',
fontFamily: '"Motiva Sans", Sans-serif',
fontSize: '16px'
}}>
Add to Cart
</div>
</div>
</div>
</div>
</div>
</div>
)
}
Thumbnail.js
import Image from "next/image";
export default function Thumbnail({url}) {
return (
<div style={{display: 'flex', justifyContent: 'center'}}>
<Image alt="Thumbnail" src={url} width={940} height={0} style={{height: 'auto'}}/>
</div>
)
}
Title.js
export default function Title({title, description}) {
return (
<div style={{display: 'flex', justifyContent: 'center'}}>
<div style={{width: '940px'}}>
<p style={{
marginBottom: '0',
fontFamily: '"Motiva Sans", Sans-serif',
fontSize: '12px',
color: '#8f98A0'
}}>{description}</p>
<p style={{marginTop: '0', fontFamily: '"Motiva Sans", Sans-serif', fontSize: '26px'}}>{title}</p>
</div>
</div>
)
}
Images
At last, for the BigShowcase component, you will need 6 images. In the public
directory, create a previewSs
folder and enter it. All images should have the .jpg
format. The mandatory banner image is named previewBanner.jpg
; The showcase images are named 1.jpg
, 2.jpg
, 3.jpg
, 4.jpg
, but you may remove them as you'd like by deleting lines 34-37 from components/BigShowcase.js
.
The last image, which is also mandatory, is called previewBg.jpg
, and you can get it from the GitHub repository
Testing the storefront
Run the Medusa server
Open the directory in which you installed the Medusa server in your terminal and run medusa develop
. In the case of 'medusa' is not recognized as an internal or external command
, run npx medusa develop
.
Run the Next.js storefront
In your terminal, open the directory in which you have created the Next.js storefront and execute npm run dev
.
CORS header Access-Control-Allow-Origin
missing
In the case of this error, open the file medusa-config.js
(it is in the directory in which you have installed the Medusa backend/server) in your preferred code editor. On line 29, you may replace the string parameter with the URL of your storefront (in this case, http://localhost:3000
).
// CORS to avoid issues when consuming Medusa from a client
const STORE_CORS = process.env.STORE_CORS || "http://localhost:3000";
Conclusion
In this tutorial, you created a Next.js eCommerce storefront based on Steam and using Medusa. This storefront only includes the front and product pages; you will need to make a checkout page and recreate the other web pages yourself, such as the COMMUNITY
or NEWS
pages.
Here is a tutorial on how to add a checkout page.