Skip to content
Learni
View all tutorials
E-commerce

How to Implement a Headless E-commerce Store with Medusa.js and Next.js in 2026

Lire en français

Introduction

Headless e-commerce revolutionizes online shopping by decoupling the frontend (storefront) from the backend (product management, inventory, orders). Unlike monolithic solutions like WooCommerce, a headless stack uses REST or GraphQL APIs for total flexibility: reuse the backend across mobile, web, and IoT.

In 2026, with Next.js 15 and Medusa.js (open-source, Node.js-based), you get native SSR/SSG performance, advanced caching, and ready-made Stripe integrations. This advanced tutorial walks you step-by-step through creating a complete storefront: product listings, persistent cart, secure checkout.

Why does it matter? Horizontal scalability (backend API scales independently), omnichannel support (same API for React Native apps), and SEO boosts via Next.js App Router. By the end, you'll have a production-ready e-commerce site optimized for 10k+ products. Estimated time: 2h setup + testing.

Prerequisites

  • Node.js 20+ and pnpm/yarn
  • Advanced knowledge of TypeScript, Next.js App Router, and GraphQL
  • Stripe account (test mode) and API keys
  • Git and GitHub basics for Vercel/Netlify deployment
  • Local PostgreSQL or Docker for Medusa (optional; SQLite fine for dev)

Install and Run the Medusa Backend

terminal
npx create-medusa-app@latest my-medusa-store --seed
cd my-medusa-store
pnpm install
pnpm run build
pnpm run start

# Note: Accès admin http://localhost:7001, API http://localhost:9000

This command creates a complete Medusa project with dev SQLite and seeds product/category data. The --seed flag populates 50+ test products. It runs on port 9000 (GraphQL/REST) and admin on 7001. Pitfall: Use PostgreSQL in production for scalability; run pnpm run typegen after install for auto-generated TS types.

Configure Stripe in Medusa

Medusa has native Stripe integration via plugin. Edit medusa-config.js to add payments. Enable Stripe webhooks for real-time order sync. Test with pk_test_... and sk_test_... keys.

Medusa Configuration with Stripe

medusa-config.js
const DATABASE_URL = "sqlite://localhost/medusa-store.sqlite";

module.exports = {
  projectConfig: {
    database_url: DATABASE_URL,
    database_type: "sqlite",
    redis_url: "redis://localhost:6379",
  },
  plugins: [
    `medusa-fulfillment-manual`,
    `medusa-payment-stripe`, {
      api_key: "sk_test_VOTRE_CLE_SECRETE_STRIPE",
      webhook_secret: "whsec_VOTRE_WEBHOOK_SECRET",
    },
    {
      resolve: `@medusajs/admin`,
    },
  ],
};

This config enables Stripe payments + manual fulfillment. Replace Stripe keys (from Stripe dashboard > Developers > API keys). Webhook handles events like payment_intent.succeeded. Pitfall: Use Redis for cart sessions in production; run pnpm run build after changes.

Create the Next.js Storefront

terminal
cd ..
npx create-next-app@15 storefront --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd storefront
pnpm add @medusajs/medusa-js @tanstack/react-query stripe @stripe/stripe-js lucide-react
pnpm add -D @types/node
pnpm run dev

Creates Next.js 15 with App Router and Tailwind for rapid UI. @medusajs/medusa-js is the official SDK for product/cart queries. React Query handles optimized caching/fetching. Runs on localhost:3000. Pitfall: Set MEDUSA_BACKEND_URL=http://localhost:9000 in .env.local.

Integrate Medusa Client and QueryClient

Create a global React Query provider with the Medusa client. This centralizes fetches (products, variants, regions). Use useQuery for SSR/ISR compatibility.

Medusa and React Query Provider

src/lib/medusa.tsx
import Medusa from "@medusajs/medusa-js";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PropsWithChildren } from "react";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,
      cacheTime: 10 * 60 * 1000,
    },
  },
});

export const medusaClient = new Medusa({
  baseUrl: process.env.MEDUSA_BACKEND_URL || "http://localhost:9000",
  maxRetries: 3,
});

export function Providers({ children }: PropsWithChildren) {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
}

Singleton Medusa client for all APIs. React Query caches for 5min (staleTime) for performance. maxRetries:3 handles flaky networks. Wrap in layout.tsx. Pitfall: Too-low staleTime causes over-fetching; test with Chrome DevTools network throttling.

Product Listing Page with Search

src/app/page.tsx
import { medusaClient } from "@/lib/medusa";
import { useQuery } from "@tanstack/react-query";
import { Providers } from "@/lib/medusa";

export default function Home() {
  const { data: products } = useQuery(
    ["products"],
    () => medusaClient.products().list({ limit: 12 }).then((r) => r.products)
  );

  return (
    <Providers>
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6 p-8">
        {products?.map((product) => (
          <div key={product.id} className="border p-4 rounded-lg">
            <img src={product.thumbnail || ""} alt={product.title} className="w-full h-48 object-cover" />
            <h3 className="font-bold mt-2">{product.title}</h3>
            <p>{product.variants?.[0]?.prices?.[0]?.amount / 100}€</p>
          </div>
        ))}
      </div>
    </Providers>
  );
}

Query fetches 12 products with thumbnail/price (first variant/price). Responsive Tailwind grid. SSR via Next.js auto-fetch. Add search: q param for filtering. Pitfall: Handle !products loading/error with Suspense; divide price by 100 since Stripe uses cents.

Manage Persistent Cart

Analogy: Like a virtual shopping cart synced with localStorage. Use Medusa Cart API for add/update/remove, with React Context for UI state.

Cart Hook with localStorage

src/hooks/useCart.tsx
import { useCart } from "medusa-react";
import { useEffect } from "react";

export function useManagedCart() {
  const { cart, setCart } = useCart();

  useEffect(() => {
    if (cart?.id) {
      localStorage.setItem("cart_id", cart.id);
    }
  }, [cart?.id]);

  const restoreCart = async () => {
    const cartId = localStorage.getItem("cart_id");
    if (cartId && !cart?.id) {
      await setCart(cartId);
    }
  };

  return { cart, restoreCart };
}

Hook restores cart via localStorage (persists on refresh). medusa-react handles auto-mutations. Call restoreCart() in layout. Pitfall: Expire carts >30 days via Medusa cron; test multi-tab with BroadcastChannel if needed.

Checkout Page with Stripe

src/app/checkout/page.tsx
import { loadStripe } from "@stripe/stripe-js";
import { medusaClient } from "@/lib/medusa";
import { useCart } from "medusa-react";
import { useState } from "react";

const stripePromise = loadStripe("pk_test_VOTRE_PUB_KEY");

export default function Checkout() {
  const { cart } = useCart();
  const [loading, setLoading] = useState(false);

  const handleCheckout = async () => {
    setLoading(true);
    const paymentSession = await medusaClient.clients.retrievePaymentSessions(cart!.id);
    const stripeSession = paymentSession!.find(s => s.provider_id === "stripe");

    const stripe = await stripePromise;
    await stripe!.redirectToCheckout({
      sessionId: stripeSession!.data!.metadata!.session_id,
    });
  };

  return (
    <div className="max-w-md mx-auto p-8">
      <h1>Total: {cart?.subtotal / 100}€</h1>
      <button
        onClick={handleCheckout}
        disabled={loading || !cart}
        className="w-full bg-blue-500 text-white p-4 mt-4 rounded"
      >
        Payer avec Stripe
      </button>
    </div>
  );
}

Creates Stripe session via Medusa payment_sessions. Redirects to Stripe Elements (no PCI compliance needed). Replace with your Stripe PK. Pitfall: Enable stripe payment provider in Medusa admin; handle cart.region for multi-country taxes.

Best Practices

  • Granular caching: Use React Query tags to invalidate selectively (e.g., invalidateQueries({ queryKey: ['cart'] }) after add).
  • Regions/taxes: Set up Medusa regions for auto EU VAT; query cart.region_id.
  • SEO/Perf: Use generateStaticParams for slug-based products, ISR reval 1h.
  • Security: Validate Stripe webhooks with HMAC, rate-limit Medusa APIs.
  • Monitoring: Integrate Sentry for errors, Prometheus for backend metrics.

Common Errors to Avoid

  • Forgetting Medusa typegen: Outdated TS types lead to runtime errors; rerun after schema changes.
  • Non-persistent cart: Without localStorage/ID restore, cart abandonment rises +50%.
  • Live Stripe without tests: Always use test clocks to simulate time.
  • No offline fallback: Add swr for stale-while-revalidate in PWAs.

Next Steps

Deploy Medusa backend on Render/DigitalOcean, storefront on Vercel. Explore Medusa modules (SendGrid emails, PostHog analytics).

Check out our Learni e-commerce headless training or Medusa v2 docs. Join Medusa Discord for contributions.