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

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

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:

FeatureMedusa 2.0ShopifyWooCommerce
Open-sourceYesNoYes
Headless-firstYesPartialPlugin
Custom modulesFirst-classLimitedPlugin hell
Multi-regionBuilt-inExpensiveManual
TypeScriptFullLiquidPHP
Self-hostedYesNoYes

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-cli

Create a New Medusa Project

medusa new my-store --v2
cd my-store

This scaffolds a Medusa 2.0 project with:

  • src/ — your custom modules and API routes
  • medusa-config.ts — central configuration
  • package.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.ts

Start the Backend

npx medusa develop

Your 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-storefront

Install Dependencies

npm install @medusajs/js-sdk @medusajs/types

Configure 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_here

You 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 &quot;{query}&quot;
      </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:

  1. MENA — currency: SAR, countries: SA, AE, KW, BH, OM, QA
  2. North Africa — currency: TND, countries: TN, DZ, MA, LY, EG
  3. 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 up

Option 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_xxx

Troubleshooting

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 PONG

Next Steps

Now that you have a working e-commerce store, consider:

  • Adding Stripe payments — Install @medusajs/payment-stripe for 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.


Want to read more tutorials? Check out our latest tutorial on Complete Guide to shadcn/ui with Next.js: Building Modern Interfaces.

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.

30 min read·

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.

30 min read·