Medusa.js 2.0 — Build a Headless E-commerce Store with Next.js (2026)

Open-source Shopify alternative, built for developers. Medusa.js 2.0 is a fully modular, headless commerce platform that lets you build custom storefronts with any frontend. In this tutorial, we pair it with Next.js to create a production-ready e-commerce store.
What You Will Learn
By the end of this tutorial, you will:
- Understand Medusa.js 2.0 architecture and its modular design
- Set up a Medusa backend with PostgreSQL
- Build a Next.js storefront connected to the Medusa API
- Display products with categories and variants
- Implement a shopping cart with add/remove functionality
- Build a checkout flow with shipping and payment
- Deploy both backend and frontend to production
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed (
node --version) - PostgreSQL 14+ running locally or via Docker
- React and Next.js knowledge (App Router, Server Components)
- TypeScript fundamentals
- A code editor — VS Code recommended
- Basic understanding of REST APIs
Why Medusa.js 2.0?
If you have ever tried to customize Shopify beyond its Liquid templates or fought with WooCommerce plugins, you know the pain. Medusa.js 2.0 takes a different approach:
| Feature | Medusa 2.0 | Shopify | WooCommerce |
|---|---|---|---|
| Open-source | Yes | No | Yes |
| Headless-first | Yes | Partial | Plugin |
| Custom modules | First-class | Limited | Plugin hell |
| Multi-region | Built-in | Expensive | Manual |
| TypeScript | Full | Liquid | PHP |
| Self-hosted | Yes | No | Yes |
Medusa 2.0 was rewritten from the ground up with a modular architecture. Every feature — products, carts, orders, payments — is a standalone module you can extend, replace, or remove.
Architecture Overview
┌─────────────────────┐ ┌──────────────────────┐
│ Next.js Frontend │────▶│ Medusa.js Backend │
│ (App Router) │ API │ (Node.js + Express) │
│ Port 3000 │◀────│ Port 9000 │
└─────────────────────┘ └──────────┬───────────┘
│
┌──────────▼───────────┐
│ PostgreSQL │
│ + Redis │
└──────────────────────┘
Step 1: Set Up the Medusa Backend
Install the Medusa CLI
npm install -g @medusajs/medusa-cliCreate a New Medusa Project
medusa new my-store --v2
cd my-storeThis scaffolds a Medusa 2.0 project with:
src/— your custom modules and API routesmedusa-config.ts— central configurationpackage.json— with all core modules pre-installed
Configure the Database
Edit medusa-config.ts:
import { defineConfig } from "@medusajs/framework/utils"
export default defineConfig({
projectConfig: {
databaseUrl: process.env.DATABASE_URL ||
"postgres://localhost/medusa-store",
redisUrl: process.env.REDIS_URL || "redis://localhost:6379",
http: {
storeCors: process.env.STORE_CORS || "http://localhost:3000",
adminCors: process.env.ADMIN_CORS || "http://localhost:9000",
jwtSecret: process.env.JWT_SECRET || "supersecret",
cookieSecret: process.env.COOKIE_SECRET || "supersecret",
},
},
})Create the Database and Seed Data
# Create the PostgreSQL database
createdb medusa-store
# Run migrations
npx medusa db:migrate
# Seed with sample products
npx medusa seed --seed-file=src/scripts/seed.tsStart the Backend
npx medusa developYour Medusa API is now running at http://localhost:9000. Test it:
curl http://localhost:9000/store/products | jq '.products[0].title'Step 2: Create the Next.js Storefront
Initialize the Project
npx create-next-app@latest my-storefront --typescript --tailwind --app --src-dir
cd my-storefrontInstall Dependencies
npm install @medusajs/js-sdk @medusajs/typesConfigure the Medusa Client
Create src/lib/medusa.ts:
import Medusa from "@medusajs/js-sdk"
export const medusa = new Medusa({
baseUrl: process.env.NEXT_PUBLIC_MEDUSA_URL || "http://localhost:9000",
publishableKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY || "",
})Add to .env.local:
NEXT_PUBLIC_MEDUSA_URL=http://localhost:9000
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_your_key_hereYou can find your publishable key in the Medusa admin at http://localhost:9000/app.
Step 3: Build the Product Catalog
Product List Page
Create src/app/products/page.tsx:
import { medusa } from "@/lib/medusa"
import Link from "next/link"
import Image from "next/image"
export default async function ProductsPage() {
const { products } = await medusa.store.product.list({
limit: 20,
fields: "+variants.calculated_price",
})
return (
<main className="max-w-7xl mx-auto px-4 py-12">
<h1 className="text-3xl font-bold mb-8">All Products</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{products.map((product) => (
<Link
key={product.id}
href={`/products/${product.handle}`}
className="group"
>
<div className="aspect-square overflow-hidden rounded-xl bg-gray-100">
{product.thumbnail && (
<Image
src={product.thumbnail}
alt={product.title}
width={500}
height={500}
className="object-cover w-full h-full group-hover:scale-105 transition-transform"
/>
)}
</div>
<h2 className="mt-3 text-lg font-medium">{product.title}</h2>
<p className="text-gray-500">
{formatPrice(product.variants?.[0]?.calculated_price)}
</p>
</Link>
))}
</div>
</main>
)
}
function formatPrice(price: any) {
if (!price?.calculated_amount) return ""
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: price.currency_code || "usd",
}).format(price.calculated_amount / 100)
}Product Detail Page
Create src/app/products/[handle]/page.tsx:
import { medusa } from "@/lib/medusa"
import { notFound } from "next/navigation"
import Image from "next/image"
import { AddToCartButton } from "@/components/add-to-cart"
interface Props {
params: Promise<{ handle: string }>
}
export default async function ProductPage({ params }: Props) {
const { handle } = await params
const { products } = await medusa.store.product.list({
handle,
fields: "+variants.calculated_price,+variants.inventory_quantity",
})
const product = products[0]
if (!product) notFound()
return (
<main className="max-w-7xl mx-auto px-4 py-12">
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
{/* Product Images */}
<div className="space-y-4">
{product.images?.map((image, i) => (
<div key={i} className="aspect-square rounded-xl overflow-hidden bg-gray-100">
<Image
src={image.url}
alt={product.title}
width={800}
height={800}
className="object-cover w-full h-full"
/>
</div>
))}
</div>
{/* Product Info */}
<div>
<h1 className="text-3xl font-bold">{product.title}</h1>
<p className="mt-2 text-2xl text-gray-700">
{formatPrice(product.variants?.[0]?.calculated_price)}
</p>
<div
className="mt-6 prose"
dangerouslySetInnerHTML={{ __html: product.description || "" }}
/>
{/* Variant Selection */}
{product.options?.map((option) => (
<div key={option.id} className="mt-6">
<h3 className="font-medium mb-2">{option.title}</h3>
<div className="flex gap-2">
{option.values?.map((value) => (
<button
key={value.id}
className="px-4 py-2 border rounded-lg hover:border-black transition"
>
{value.value}
</button>
))}
</div>
</div>
))}
<AddToCartButton
productId={product.id}
variantId={product.variants?.[0]?.id || ""}
/>
</div>
</div>
</main>
)
}
function formatPrice(price: any) {
if (!price?.calculated_amount) return ""
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: price.currency_code || "usd",
}).format(price.calculated_amount / 100)
}Step 4: Implement the Shopping Cart
Cart Context Provider
Create src/lib/cart-context.tsx:
"use client"
import { createContext, useContext, useState, useEffect, useCallback } from "react"
import { medusa } from "@/lib/medusa"
interface CartContextType {
cart: any
addItem: (variantId: string, quantity?: number) => Promise<void>
removeItem: (lineItemId: string) => Promise<void>
updateQuantity: (lineItemId: string, quantity: number) => Promise<void>
itemCount: number
}
const CartContext = createContext<CartContextType | null>(null)
export function CartProvider({ children }: { children: React.ReactNode }) {
const [cart, setCart] = useState<any>(null)
const initCart = useCallback(async () => {
let cartId = localStorage.getItem("cart_id")
if (cartId) {
try {
const { cart } = await medusa.store.cart.retrieve(cartId)
setCart(cart)
return
} catch {
localStorage.removeItem("cart_id")
}
}
const { cart: newCart } = await medusa.store.cart.create({})
localStorage.setItem("cart_id", newCart.id)
setCart(newCart)
}, [])
useEffect(() => {
initCart()
}, [initCart])
const addItem = async (variantId: string, quantity = 1) => {
if (!cart) return
const { cart: updated } = await medusa.store.cart.addLineItem(cart.id, {
variant_id: variantId,
quantity,
})
setCart(updated)
}
const removeItem = async (lineItemId: string) => {
if (!cart) return
const { cart: updated } = await medusa.store.cart.removeLineItem(
cart.id,
lineItemId
)
setCart(updated)
}
const updateQuantity = async (lineItemId: string, quantity: number) => {
if (!cart) return
const { cart: updated } = await medusa.store.cart.updateLineItem(
cart.id,
lineItemId,
{ quantity }
)
setCart(updated)
}
const itemCount = cart?.items?.reduce(
(sum: number, item: any) => sum + item.quantity, 0
) || 0
return (
<CartContext.Provider value={{ cart, addItem, removeItem, updateQuantity, itemCount }}>
{children}
</CartContext.Provider>
)
}
export const useCart = () => {
const ctx = useContext(CartContext)
if (!ctx) throw new Error("useCart must be used within CartProvider")
return ctx
}Add to Cart Button
Create src/components/add-to-cart.tsx:
"use client"
import { useCart } from "@/lib/cart-context"
import { useState } from "react"
export function AddToCartButton({
variantId,
}: {
productId: string
variantId: string
}) {
const { addItem } = useCart()
const [loading, setLoading] = useState(false)
const handleAdd = async () => {
setLoading(true)
await addItem(variantId)
setLoading(false)
}
return (
<button
onClick={handleAdd}
disabled={loading}
className="mt-8 w-full bg-black text-white py-4 rounded-xl font-medium
hover:bg-gray-800 disabled:opacity-50 transition"
>
{loading ? "Adding..." : "Add to Cart"}
</button>
)
}Cart Drawer Component
Create src/components/cart-drawer.tsx:
"use client"
import { useCart } from "@/lib/cart-context"
import { useState } from "react"
import Image from "next/image"
import Link from "next/link"
export function CartDrawer() {
const { cart, removeItem, updateQuantity, itemCount } = useCart()
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)} className="relative">
Cart
{itemCount > 0 && (
<span className="absolute -top-2 -right-3 bg-black text-white text-xs
w-5 h-5 rounded-full flex items-center justify-center">
{itemCount}
</span>
)}
</button>
{open && (
<div className="fixed inset-0 z-50 flex justify-end">
<div className="bg-black/50 flex-1" onClick={() => setOpen(false)} />
<div className="w-full max-w-md bg-white h-full overflow-y-auto p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold">Your Cart</h2>
<button onClick={() => setOpen(false)}>Close</button>
</div>
{!cart?.items?.length ? (
<p className="text-gray-500">Your cart is empty.</p>
) : (
<>
{cart.items.map((item: any) => (
<div key={item.id} className="flex gap-4 py-4 border-b">
{item.thumbnail && (
<Image
src={item.thumbnail}
alt={item.title}
width={80}
height={80}
className="rounded-lg object-cover"
/>
)}
<div className="flex-1">
<h3 className="font-medium">{item.title}</h3>
<p className="text-sm text-gray-500">{item.variant?.title}</p>
<div className="flex items-center gap-2 mt-2">
<button
onClick={() => updateQuantity(item.id, item.quantity - 1)}
disabled={item.quantity <= 1}
className="w-8 h-8 border rounded"
>
-
</button>
<span>{item.quantity}</span>
<button
onClick={() => updateQuantity(item.id, item.quantity + 1)}
className="w-8 h-8 border rounded"
>
+
</button>
</div>
</div>
<button
onClick={() => removeItem(item.id)}
className="text-red-500 text-sm"
>
Remove
</button>
</div>
))}
<div className="mt-6 pt-4 border-t">
<div className="flex justify-between text-lg font-bold">
<span>Total</span>
<span>{formatPrice(cart.total, cart.currency_code)}</span>
</div>
<Link
href="/checkout"
onClick={() => setOpen(false)}
className="block mt-4 w-full bg-black text-white text-center
py-4 rounded-xl font-medium hover:bg-gray-800"
>
Proceed to Checkout
</Link>
</div>
</>
)}
</div>
</div>
)}
</>
)
}
function formatPrice(amount: number, currency: string) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency || "usd",
}).format(amount / 100)
}Step 5: Build the Checkout Flow
Checkout Page
Create src/app/checkout/page.tsx:
"use client"
import { useCart } from "@/lib/cart-context"
import { medusa } from "@/lib/medusa"
import { useState } from "react"
import { useRouter } from "next/navigation"
export default function CheckoutPage() {
const { cart } = useCart()
const router = useRouter()
const [loading, setLoading] = useState(false)
const [step, setStep] = useState<"shipping" | "payment">("shipping")
const [shippingForm, setShippingForm] = useState({
first_name: "",
last_name: "",
address_1: "",
city: "",
country_code: "us",
postal_code: "",
phone: "",
email: "",
})
const handleShippingSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!cart) return
setLoading(true)
// Add email to cart
await medusa.store.cart.update(cart.id, {
email: shippingForm.email,
})
// Add shipping address
await medusa.store.cart.update(cart.id, {
shipping_address: {
first_name: shippingForm.first_name,
last_name: shippingForm.last_name,
address_1: shippingForm.address_1,
city: shippingForm.city,
country_code: shippingForm.country_code,
postal_code: shippingForm.postal_code,
phone: shippingForm.phone,
},
})
// Get shipping options
const { shipping_options } = await medusa.store.fulfillment
.listCartOptions({ cart_id: cart.id })
if (shipping_options?.length) {
await medusa.store.cart.addShippingMethod(cart.id, {
option_id: shipping_options[0].id,
})
}
setLoading(false)
setStep("payment")
}
const handlePaymentSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!cart) return
setLoading(true)
// Initialize payment session
await medusa.store.cart.initiatePaymentSession(cart.id, {
provider_id: "pp_system_default",
})
// Complete the cart (create order)
const { order } = await medusa.store.cart.complete(cart.id) as any
localStorage.removeItem("cart_id")
router.push(`/order/${order.id}`)
}
if (!cart) return <div className="p-12 text-center">Loading cart...</div>
return (
<main className="max-w-2xl mx-auto px-4 py-12">
<h1 className="text-3xl font-bold mb-8">Checkout</h1>
{/* Progress indicator */}
<div className="flex gap-4 mb-8">
<span className={step === "shipping" ? "font-bold" : "text-gray-400"}>
1. Shipping
</span>
<span className={step === "payment" ? "font-bold" : "text-gray-400"}>
2. Payment
</span>
</div>
{step === "shipping" && (
<form onSubmit={handleShippingSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<input
placeholder="First Name"
required
value={shippingForm.first_name}
onChange={(e) => setShippingForm(f => ({ ...f, first_name: e.target.value }))}
className="border rounded-lg px-4 py-3"
/>
<input
placeholder="Last Name"
required
value={shippingForm.last_name}
onChange={(e) => setShippingForm(f => ({ ...f, last_name: e.target.value }))}
className="border rounded-lg px-4 py-3"
/>
</div>
<input
placeholder="Email"
type="email"
required
value={shippingForm.email}
onChange={(e) => setShippingForm(f => ({ ...f, email: e.target.value }))}
className="w-full border rounded-lg px-4 py-3"
/>
<input
placeholder="Address"
required
value={shippingForm.address_1}
onChange={(e) => setShippingForm(f => ({ ...f, address_1: e.target.value }))}
className="w-full border rounded-lg px-4 py-3"
/>
<div className="grid grid-cols-2 gap-4">
<input
placeholder="City"
required
value={shippingForm.city}
onChange={(e) => setShippingForm(f => ({ ...f, city: e.target.value }))}
className="border rounded-lg px-4 py-3"
/>
<input
placeholder="Postal Code"
required
value={shippingForm.postal_code}
onChange={(e) => setShippingForm(f => ({ ...f, postal_code: e.target.value }))}
className="border rounded-lg px-4 py-3"
/>
</div>
<input
placeholder="Phone"
value={shippingForm.phone}
onChange={(e) => setShippingForm(f => ({ ...f, phone: e.target.value }))}
className="w-full border rounded-lg px-4 py-3"
/>
<button
type="submit"
disabled={loading}
className="w-full bg-black text-white py-4 rounded-xl font-medium
hover:bg-gray-800 disabled:opacity-50"
>
{loading ? "Processing..." : "Continue to Payment"}
</button>
</form>
)}
{step === "payment" && (
<form onSubmit={handlePaymentSubmit} className="space-y-4">
<div className="bg-gray-50 rounded-xl p-6">
<h3 className="font-medium mb-2">Order Summary</h3>
{cart.items?.map((item: any) => (
<div key={item.id} className="flex justify-between py-2">
<span>{item.title} x {item.quantity}</span>
<span>{formatPrice(item.subtotal, cart.currency_code)}</span>
</div>
))}
<div className="border-t mt-4 pt-4 flex justify-between font-bold">
<span>Total</span>
<span>{formatPrice(cart.total, cart.currency_code)}</span>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-black text-white py-4 rounded-xl font-medium
hover:bg-gray-800 disabled:opacity-50"
>
{loading ? "Placing Order..." : "Place Order"}
</button>
</form>
)}
</main>
)
}
function formatPrice(amount: number, currency: string) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency || "usd",
}).format(amount / 100)
}Step 6: Product Categories and Filtering
Fetch Categories
Create src/app/categories/[handle]/page.tsx:
import { medusa } from "@/lib/medusa"
import Link from "next/link"
import Image from "next/image"
interface Props {
params: Promise<{ handle: string }>
}
export default async function CategoryPage({ params }: Props) {
const { handle } = await params
// Get category by handle
const { product_categories } = await medusa.store.category.list({
handle,
})
const category = product_categories[0]
if (!category) return <div>Category not found</div>
// Get products in this category
const { products } = await medusa.store.product.list({
category_id: [category.id],
fields: "+variants.calculated_price",
})
return (
<main className="max-w-7xl mx-auto px-4 py-12">
<h1 className="text-3xl font-bold mb-2">{category.name}</h1>
<p className="text-gray-500 mb-8">{category.description}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{products.map((product) => (
<Link key={product.id} href={`/products/${product.handle}`} className="group">
<div className="aspect-square overflow-hidden rounded-xl bg-gray-100">
{product.thumbnail && (
<Image
src={product.thumbnail}
alt={product.title}
width={500}
height={500}
className="object-cover w-full h-full group-hover:scale-105 transition-transform"
/>
)}
</div>
<h2 className="mt-3 text-lg font-medium">{product.title}</h2>
</Link>
))}
</div>
</main>
)
}Categories Navigation
Create src/components/category-nav.tsx:
import { medusa } from "@/lib/medusa"
import Link from "next/link"
export async function CategoryNav() {
const { product_categories } = await medusa.store.category.list({
parent_category_id: "null",
})
return (
<nav className="flex gap-6 overflow-x-auto py-4 px-4 border-b">
<Link href="/products" className="whitespace-nowrap hover:text-black text-gray-600">
All Products
</Link>
{product_categories.map((cat) => (
<Link
key={cat.id}
href={`/categories/${cat.handle}`}
className="whitespace-nowrap hover:text-black text-gray-600"
>
{cat.name}
</Link>
))}
</nav>
)
}Step 7: Search Functionality
Create src/app/search/page.tsx:
"use client"
import { medusa } from "@/lib/medusa"
import { useState, useEffect } from "react"
import Link from "next/link"
import Image from "next/image"
import { useSearchParams } from "next/navigation"
export default function SearchPage() {
const searchParams = useSearchParams()
const query = searchParams.get("q") || ""
const [products, setProducts] = useState<any[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!query) return
setLoading(true)
medusa.store.product
.list({ q: query, limit: 20 })
.then(({ products }) => setProducts(products))
.finally(() => setLoading(false))
}, [query])
return (
<main className="max-w-7xl mx-auto px-4 py-12">
<h1 className="text-3xl font-bold mb-8">
Search results for "{query}"
</h1>
{loading && <p>Searching...</p>}
{!loading && products.length === 0 && query && (
<p className="text-gray-500">No products found.</p>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{products.map((product) => (
<Link key={product.id} href={`/products/${product.handle}`} className="group">
<div className="aspect-square overflow-hidden rounded-xl bg-gray-100">
{product.thumbnail && (
<Image
src={product.thumbnail}
alt={product.title}
width={500}
height={500}
className="object-cover w-full h-full group-hover:scale-105 transition-transform"
/>
)}
</div>
<h2 className="mt-3 text-lg font-medium">{product.title}</h2>
</Link>
))}
</div>
</main>
)
}Step 8: Multi-Region Support
One of Medusa 2.0's killer features is built-in multi-region support. This is perfect for MENA e-commerce stores that serve multiple countries.
Configure Regions
In the Medusa admin (http://localhost:9000/app), create regions:
- MENA — currency: SAR, countries: SA, AE, KW, BH, OM, QA
- North Africa — currency: TND, countries: TN, DZ, MA, LY, EG
- International — currency: USD, countries: US, GB, DE, FR
Region Detection in Next.js
Create src/lib/region.ts:
import { medusa } from "./medusa"
export async function getRegionByCountry(countryCode: string) {
const { regions } = await medusa.store.region.list()
return regions.find((region) =>
region.countries?.some((c) => c.iso_2 === countryCode)
)
}
export async function getDefaultRegion() {
const { regions } = await medusa.store.region.list()
return regions[0]
}Step 9: Deploy to Production
Deploy the Medusa Backend
Option A: Railway (Recommended)
# Install Railway CLI
npm install -g @railway/cli
# Login and deploy
railway login
railway init
railway add --plugin postgresql
railway add --plugin redis
# Set environment variables
railway variables set JWT_SECRET="your-production-secret"
railway variables set COOKIE_SECRET="your-cookie-secret"
railway variables set STORE_CORS="https://your-storefront.vercel.app"
# Deploy
railway upOption B: Docker
Create Dockerfile:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
RUN npm run build
EXPOSE 9000
CMD ["npm", "start"]Deploy the Next.js Storefront
# Deploy to Vercel
npx vercel --prod
# Set environment variables in Vercel dashboard
# NEXT_PUBLIC_MEDUSA_URL=https://your-medusa-backend.railway.app
# NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_live_xxxTroubleshooting
Common Issues
CORS errors when connecting frontend to backend
Make sure STORE_CORS in your Medusa config includes your frontend URL:
storeCors: "http://localhost:3000,https://your-store.vercel.app"Products not showing prices
Ensure you pass the region_id or have a publishable API key with a default sales channel:
const { products } = await medusa.store.product.list({
region_id: "reg_xxx",
fields: "+variants.calculated_price",
})Cart creation failing
Check that Redis is running — Medusa 2.0 uses Redis for cart sessions:
redis-cli ping # Should return PONGNext Steps
Now that you have a working e-commerce store, consider:
- Adding Stripe payments — Install
@medusajs/payment-stripefor real payment processing - Setting up webhooks — Handle order events for email notifications
- Building an admin dashboard — Use the Medusa Admin UI or build custom pages
- Adding product reviews — Create a custom module for user reviews
- Implementing wishlists — Use Medusa's custom endpoints to store favorites
- SEO optimization — Add meta tags, structured data, and sitemaps
Conclusion
You have built a complete headless e-commerce store with Medusa.js 2.0 and Next.js. The modular architecture means you can swap out any piece — use Stripe instead of the default payment provider, add a custom fulfillment module, or connect a different frontend altogether.
Medusa 2.0 is a serious open-source alternative to Shopify for developers who want full control over their commerce stack. The combination with Next.js gives you server-side rendering, static generation, and a world-class developer experience.
The full source code for this tutorial is available as a starting point — customize it for your market, add your branding, and launch your store.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build a Full-Stack App with Strapi 5 and Next.js 15 App Router
Learn how to build a full-stack application with Strapi 5 as your headless CMS and Next.js 15 App Router for the frontend. This tutorial covers content modeling, REST APIs, server-side rendering, and production deployment.

Build a Type-Safe GraphQL API with Next.js App Router, Yoga, and Pothos
Learn how to build a fully type-safe GraphQL API using Next.js 15 App Router, GraphQL Yoga, and Pothos schema builder. This hands-on tutorial covers schema design, queries, mutations, authentication middleware, and a React client with urql.

Building a Content-Driven Website with Payload CMS 3 and Next.js
Learn how to build a full-featured content-driven website using Payload CMS 3, which runs natively inside Next.js App Router. This tutorial covers collections, rich text editing, media uploads, authentication, and deploying to production.