Next.js + Medusa: The Complete Headless Commerce Setup Guide
Next.js + Medusa: The Complete Headless Commerce Setup Guide
Headless commerce is no longer a buzzword reserved for enterprise brands. With tools like Next.js and Medusa, you can build a custom e-commerce platform that rivals Shopify, but with complete control over the frontend, backend, and customer experience.
I built this exact stack for NeedThisDone.com, and I've helped clients migrate from rigid platforms like Shopify Plus to flexible headless architectures. In this guide, I'll show you exactly how to set up Next.js with Medusa, from local development to production deployment.
Why Headless Commerce in 2026?
Traditional e-commerce platforms bundle everything together: the storefront, admin panel, checkout flow, and backend. This tight coupling creates limitations:
- Locked-in design: You're stuck with themes and templates
- Performance costs: Heavy JavaScript bundles and waterfall requests
- Limited customization: Want a unique checkout? Too bad
- Vendor lock-in: Migrating data is painful
- Scaling challenges: One bottleneck affects everything
Headless commerce separates concerns:
- Frontend: Next.js handles the storefront (or multiple storefronts)
- Backend: Medusa manages products, orders, inventory
- Admin: Medusa's dashboard for catalog management
- Checkout: Custom flows with Stripe or any payment provider
This separation gives you:
- Flexibility: Build any frontend experience you want
- Performance: Server-side rendering, edge caching, optimized bundles
- Portability: Switch frontends without touching your catalog
- Scalability: Scale frontend and backend independently
- Cost savings: No platform fees eating your margins
The upfront complexity is higher than Shopify's "click to launch" model, but the long-term benefits are substantial. For businesses doing $50K+ monthly revenue, the investment pays off.
Project Overview
Here's what we're building:
Tech stack:
- Next.js 15: App Router, Server Components, Server Actions
- Medusa: Headless commerce backend with admin dashboard
- Supabase: PostgreSQL database with real-time subscriptions
- Stripe: Payment processing and checkout sessions
- Railway: Medusa backend hosting with auto-scaling
- Vercel: Next.js storefront hosting with edge functions
What you'll learn:
- Deploying Medusa to Railway
- Connecting Next.js to Medusa's API
- Implementing cart state with React Context
- Building Stripe checkout flows
- Handling webhooks for order confirmation
- Deployment best practices
Let's get started.
Part 1: Medusa Backend Setup
First, create a new Medusa project locally:
npx create-medusa-app@latest
# Choose:
# - Project name: my-store-backend
# - Medusa version: Latest
# - Starter: medusa-starter-default
# - Database: PostgreSQL (we'll use Supabase)
Configure your database connection. Create a new Supabase project, then update your .env file:
# medusa/.env
DATABASE_URL=postgresql://postgres:[PASSWORD]@[PROJECT_ID].supabase.co:5432/postgres
REDIS_URL=redis://localhost:6379
JWT_SECRET=your-secret-key
COOKIE_SECRET=your-cookie-secret
Start Medusa locally:
cd medusa
npm run dev
Access the admin dashboard at http://localhost:7001/app. Create your admin user:
npx medusa user -e admin@mystore.com -p password123
Railway deployment:
- Push your Medusa code to GitHub
- Create a Railway project
- Connect your GitHub repo
- Add environment variables (same as
.env) - Railway auto-deploys on every push
Your Medusa backend is now live. The admin dashboard URL will be https://your-project.railway.app/app.
Part 2: Next.js Storefront
Create a new Next.js project:
npx create-next-app@latest my-store-frontend
# Choose:
# - TypeScript: Yes
# - Tailwind CSS: Yes
# - App Router: Yes
Install Medusa's JavaScript client:
cd my-store-frontend
npm install @medusajs/medusa-js
Create a Medusa client wrapper:
// lib/medusa-client.ts
import Medusa from '@medusajs/medusa-js';
export const medusaClient = new Medusa({
baseUrl: process.env.NEXT_PUBLIC_MEDUSA_URL || 'http://localhost:9000',
maxRetries: 3,
});
Fetch products with Server Components:
// app/shop/page.tsx
import { medusaClient } from '@/lib/medusa-client';
import Link from 'next/link';
import Image from 'next/image';
export default async function ShopPage() {
const { products } = await medusaClient.products.list();
return (
<div className="max-w-7xl mx-auto px-4 py-12">
<h1 className="text-4xl font-bold text-gray-900 mb-8">Shop</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{products.map((product) => {
const thumbnail = product.thumbnail || '/placeholder.png';
const price = product.variants[0]?.prices[0]?.amount || 0;
return (
<Link
key={product.id}
href={`/shop/${product.id}`}
className="group"
>
<div className="relative aspect-square mb-4 overflow-hidden rounded-lg bg-gray-100">
<Image
src={thumbnail}
alt={product.title}
fill
className="object-cover group-hover:scale-105 transition-transform"
/>
</div>
<h3 className="text-lg font-semibold text-gray-900">
{product.title}
</h3>
<p className="text-gray-600 mt-1">
${(price / 100).toFixed(2)}
</p>
</Link>
);
})}
</div>
</div>
);
}
Product detail page:
// app/shop/[productId]/page.tsx
import { medusaClient } from '@/lib/medusa-client';
import AddToCartButton from '@/components/AddToCartButton';
import { notFound } from 'next/navigation';
export default async function ProductPage({
params,
}: {
params: { productId: string };
}) {
const { product } = await medusaClient.products.retrieve(params.productId);
if (!product) {
notFound();
}
const variant = product.variants[0];
const price = variant.prices[0]?.amount || 0;
return (
<div className="max-w-7xl mx-auto px-4 py-12">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
<div className="aspect-square bg-gray-100 rounded-lg" />
<div>
<h1 className="text-4xl font-bold text-gray-900 mb-4">
{product.title}
</h1>
<p className="text-2xl text-emerald-600 mb-6">
${(price / 100).toFixed(2)}
</p>
<p className="text-gray-700 mb-8">{product.description}</p>
<AddToCartButton variantId={variant.id} />
</div>
</div>
</div>
);
}
Server Components fetch data at request time. No loading spinners, no client-side waterfalls. Just fast, SEO-friendly pages.
Part 3: Cart Implementation
Create a CartContext to manage cart state:
// context/CartContext.tsx
'use client';
import { createContext, useContext, useState, useEffect } from 'react';
import { medusaClient } from '@/lib/medusa-client';
interface CartContextType {
cartId: string | null;
itemCount: number;
addItem: (variantId: string, quantity: number) => Promise<void>;
removeItem: (lineId: string) => Promise<void>;
updateQuantity: (lineId: string, quantity: number) => Promise<void>;
}
const CartContext = createContext<CartContextType | null>(null);
export function CartProvider({ children }: { children: React.ReactNode }) {
const [cartId, setCartId] = useState<string | null>(null);
const [itemCount, setItemCount] = useState(0);
// Load cart ID from localStorage on mount
useEffect(() => {
const storedCartId = localStorage.getItem('medusa_cart_id');
if (storedCartId) {
setCartId(storedCartId);
refreshItemCount(storedCartId);
}
}, []);
const refreshItemCount = async (id: string) => {
const { cart } = await medusaClient.carts.retrieve(id);
const count = cart.items.reduce((sum, item) => sum + item.quantity, 0);
setItemCount(count);
};
const addItem = async (variantId: string, quantity: number) => {
let id = cartId;
// Create cart if it doesn't exist
if (!id) {
const { cart } = await medusaClient.carts.create();
id = cart.id;
setCartId(id);
localStorage.setItem('medusa_cart_id', id);
}
// Add item to cart
await medusaClient.carts.lineItems.create(id, {
variant_id: variantId,
quantity,
});
await refreshItemCount(id);
};
const removeItem = async (lineId: string) => {
if (!cartId) return;
await medusaClient.carts.lineItems.delete(cartId, lineId);
await refreshItemCount(cartId);
};
const updateQuantity = async (lineId: string, quantity: number) => {
if (!cartId) return;
await medusaClient.carts.lineItems.update(cartId, lineId, { quantity });
await refreshItemCount(cartId);
};
return (
<CartContext.Provider
value={{ cartId, itemCount, addItem, removeItem, updateQuantity }}
>
{children}
</CartContext.Provider>
);
}
export const useCart = () => {
const context = useContext(CartContext);
if (!context) throw new Error('useCart must be used within CartProvider');
return context;
};
Add to cart button:
// components/AddToCartButton.tsx
'use client';
import { useState } from 'react';
import { useCart } from '@/context/CartContext';
export default function AddToCartButton({ variantId }: { variantId: string }) {
const { addItem } = useCart();
const [isAdding, setIsAdding] = useState(false);
const handleClick = async () => {
setIsAdding(true);
try {
await addItem(variantId, 1);
} catch (error) {
console.error('Failed to add to cart:', error);
} finally {
setIsAdding(false);
}
};
return (
<button
onClick={handleClick}
disabled={isAdding}
className="w-full py-4 bg-emerald-600 text-white font-semibold rounded-lg hover:bg-emerald-700 disabled:opacity-50 transition-colors"
>
{isAdding ? 'Adding...' : 'Add to Cart'}
</button>
);
}
This pattern keeps cart state synchronized across all components. Add an item on the product page, and the cart icon in the header updates instantly.
Part 4: Stripe Checkout Flow
Create a checkout session API route:
// app/api/checkout/session/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { medusaClient } from '@/lib/medusa-client';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
});
export async function POST(req: NextRequest) {
const { cartId } = await req.json();
// Get cart from Medusa
const { cart } = await medusaClient.carts.retrieve(cartId);
// Create Stripe checkout session
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: cart.items.map((item) => ({
price_data: {
currency: 'usd',
product_data: {
name: item.title,
description: item.description,
},
unit_amount: item.unit_price,
},
quantity: item.quantity,
})),
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/cart`,
metadata: {
cart_id: cartId,
},
});
return NextResponse.json({ url: session.url });
}
Handle webhooks to complete orders:
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { medusaClient } from '@/lib/medusa-client';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
});
export async function POST(req: NextRequest) {
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
const cartId = session.metadata?.cart_id;
if (cartId) {
// Complete cart in Medusa (creates order)
await medusaClient.carts.complete(cartId);
}
}
return NextResponse.json({ received: true });
}
This flow ensures orders are only created after successful payment. Stripe webhooks handle async payment confirmation, preventing race conditions.
I cover similar reliability patterns in my post on request deduplication.
Deployment Checklist
Before going live, verify these configurations:
Environment variables (Vercel):
NEXT_PUBLIC_MEDUSA_URL: Your Railway Medusa URLNEXT_PUBLIC_APP_URL: Your Vercel domainSTRIPE_SECRET_KEY: Stripe secret keySTRIPE_WEBHOOK_SECRET: Webhook signing secret
Vercel configuration (vercel.json):
{
"buildCommand": "cd app && npm run build",
"installCommand": "cd app && npm install",
"outputDirectory": "app/.next",
"framework": "nextjs"
}
Common pitfalls:
- Forgot to run Medusa migrations:
npx medusa migrations run - CORS errors: Add your Vercel domain to Medusa's
STORE_CORSenv var - Webhook failures: Test locally with Stripe CLI before deploying
- Missing API keys: Double-check all environment variables
Testing:
- Complete a test purchase with Stripe test cards
- Verify order appears in Medusa admin
- Check email notifications are sent
- Test cart persistence across sessions
I've deployed dozens of headless commerce sites. The architecture I laid out here is battle-tested and production-ready. For more deployment insights, check out my post on building my own e-commerce platform.
When to Hire a Developer
Headless commerce gives you power, but it requires technical expertise. Consider hiring a developer if you're experiencing:
Complexity signals:
- Custom checkout flows (subscriptions, multi-currency, payment plans)
- Multi-storefront setups (B2B and B2C from one catalog)
- Complex inventory logic (bundles, made-to-order, dropshipping)
- Integration requirements (ERP, CRM, fulfillment centers)
- Performance issues (slow pages, high bounce rates)
Business signals:
- Outgrowing Shopify's capabilities
- Platform fees eating into margins
- Need custom features competitors don't have
- Scaling beyond 1000 SKUs or 10K monthly orders
Building e-commerce platforms is one of my core services. I specialize in Next.js + Medusa architectures that scale from MVP to enterprise. Check out my pricing for project packages or view my portfolio to see similar builds.
If you're ready to break free from platform limitations, get in touch. I'll help you build a commerce experience that's uniquely yours.
Need Help Getting Things Done?
Whether it's a project you've been putting off or ongoing support you need, we're here to help.