Medusa.js 2.0 — Construire une boutique e-commerce Headless avec Next.js (2026)

AI Bot
Par AI Bot ·

Chargement du lecteur de synthèse vocale...

Alternative open-source à Shopify, conçue pour les développeurs. Medusa.js 2.0 est une plateforme e-commerce modulaire et headless qui vous permet de construire des vitrines personnalisées avec tout framework. Dans ce tutoriel, nous la combinons avec Next.js pour créer une boutique prête pour la production.

Ce que vous allez apprendre

À la fin de ce tutoriel, vous serez capable de :

  • Comprendre l'architecture de Medusa.js 2.0 et sa conception modulaire
  • Configurer un backend Medusa avec PostgreSQL
  • Construire une vitrine Next.js connectée à l'API Medusa
  • Afficher les produits avec catégories et variantes
  • Implémenter un panier avec fonctionnalités d'ajout et suppression
  • Construire un flux de paiement avec livraison et paiement
  • Déployer le backend et le frontend en production

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20+ installé (node --version)
  • PostgreSQL 14+ en local ou via Docker
  • Connaissances en React et Next.js (App Router, Server Components)
  • Bases en TypeScript
  • Un éditeur de code — VS Code recommandé
  • Compréhension basique des API REST

Pourquoi Medusa.js 2.0 ?

Si vous avez déjà essayé de personnaliser Shopify au-delà de ses templates Liquid ou lutté avec les plugins WooCommerce, vous connaissez la douleur. Medusa.js 2.0 adopte une approche différente :

FonctionnalitéMedusa 2.0ShopifyWooCommerce
Open-sourceOuiNonOui
Headless-firstOuiPartielPlugin
Modules customPremière classeLimitéPlugins
Multi-régionIntégréCoûteuxManuel
TypeScriptCompletLiquidPHP
Auto-hébergéOuiNonOui

Medusa 2.0 a été entièrement réécrit avec une architecture modulaire. Chaque fonctionnalité — produits, panier, commandes, paiements — est un module autonome que vous pouvez étendre, remplacer ou supprimer.

Vue d'ensemble de l'architecture

┌─────────────────────┐     ┌──────────────────────┐
│   Frontend Next.js  │────▶│   Backend Medusa.js   │
│   (App Router)      │ API │   (Node.js + Express) │
│   Port 3000         │◀────│   Port 9000            │
└─────────────────────┘     └──────────┬───────────┘
                                       │
                            ┌──────────▼───────────┐
                            │     PostgreSQL        │
                            │     + Redis           │
                            └──────────────────────┘

Étape 1 : Configurer le backend Medusa

Installer le CLI Medusa

npm install -g @medusajs/medusa-cli

Créer un nouveau projet Medusa

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

Cela génère un projet Medusa 2.0 contenant :

  • src/ — vos modules personnalisés et routes API
  • medusa-config.ts — configuration centrale
  • package.json — avec tous les modules core pré-installés

Configurer la base de données

Modifiez 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",
    },
  },
})

Créer la base de données et injecter les données

# Créer la base de données PostgreSQL
createdb medusa-store
 
# Exécuter les migrations
npx medusa db:migrate
 
# Injecter des produits de démonstration
npx medusa seed --seed-file=src/scripts/seed.ts

Lancer le backend

npx medusa develop

Votre API Medusa fonctionne maintenant sur http://localhost:9000. Testez :

curl http://localhost:9000/store/products | jq '.products[0].title'

Étape 2 : Créer la vitrine Next.js

Initialiser le projet

npx create-next-app@latest my-storefront --typescript --tailwind --app --src-dir
cd my-storefront

Installer les dépendances

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

Configurer le client Medusa

Créez 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 || "",
})

Ajoutez à .env.local :

NEXT_PUBLIC_MEDUSA_URL=http://localhost:9000
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_your_key_here

Vous trouverez votre clé API publiable dans l'admin Medusa à http://localhost:9000/app.


Étape 3 : Construire le catalogue produits

Page liste des produits

Créez 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">Tous les produits</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("fr-FR", {
    style: "currency",
    currency: price.currency_code || "eur",
  }).format(price.calculated_amount / 100)
}

Page détail produit

Créez 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">
        {/* Images produit */}
        <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>
 
        {/* Informations produit */}
        <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 || "" }}
          />
 
          {/* Sélection variante */}
          {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("fr-FR", {
    style: "currency",
    currency: price.currency_code || "eur",
  }).format(price.calculated_amount / 100)
}

Étape 4 : Implémenter le panier

Fournisseur de contexte du panier

Créez 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
}

Bouton Ajouter au panier

Créez 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 ? "Ajout en cours..." : "Ajouter au panier"}
    </button>
  )
}

Composant tiroir du panier

Créez 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">
        Panier
        {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">Votre panier</h2>
              <button onClick={() => setOpen(false)}>Fermer</button>
            </div>
 
            {!cart?.items?.length ? (
              <p className="text-gray-500">Votre panier est vide.</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"
                    >
                      Supprimer
                    </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"
                  >
                    Passer la commande
                  </Link>
                </div>
              </>
            )}
          </div>
        </div>
      )}
    </>
  )
}
 
function formatPrice(amount: number, currency: string) {
  return new Intl.NumberFormat("fr-FR", {
    style: "currency",
    currency: currency || "eur",
  }).format(amount / 100)
}

Étape 5 : Construire le flux de paiement

Page de paiement

Créez 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: "tn",
    postal_code: "",
    phone: "",
    email: "",
  })
 
  const handleShippingSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    if (!cart) return
 
    setLoading(true)
 
    await medusa.store.cart.update(cart.id, {
      email: shippingForm.email,
    })
 
    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,
      },
    })
 
    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)
 
    await medusa.store.cart.initiatePaymentSession(cart.id, {
      provider_id: "pp_system_default",
    })
 
    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">Chargement du panier...</div>
 
  return (
    <main className="max-w-2xl mx-auto px-4 py-12">
      <h1 className="text-3xl font-bold mb-8">Paiement</h1>
 
      <div className="flex gap-4 mb-8">
        <span className={step === "shipping" ? "font-bold" : "text-gray-400"}>
          1. Livraison
        </span>
        <span className={step === "payment" ? "font-bold" : "text-gray-400"}>
          2. Paiement
        </span>
      </div>
 
      {step === "shipping" && (
        <form onSubmit={handleShippingSubmit} className="space-y-4">
          <div className="grid grid-cols-2 gap-4">
            <input
              placeholder="Prénom"
              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="Nom"
              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="Adresse"
            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="Ville"
              required
              value={shippingForm.city}
              onChange={(e) => setShippingForm(f => ({ ...f, city: e.target.value }))}
              className="border rounded-lg px-4 py-3"
            />
            <input
              placeholder="Code postal"
              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="Télé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 ? "Traitement..." : "Continuer vers le paiement"}
          </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">Résumé de la commande</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 ? "Confirmation en cours..." : "Confirmer la commande"}
          </button>
        </form>
      )}
    </main>
  )
}
 
function formatPrice(amount: number, currency: string) {
  return new Intl.NumberFormat("fr-FR", {
    style: "currency",
    currency: currency || "eur",
  }).format(amount / 100)
}

Étape 6 : Catégories et filtrage des produits

Récupérer les catégories

Créez 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
 
  const { product_categories } = await medusa.store.category.list({
    handle,
  })
 
  const category = product_categories[0]
  if (!category) return <div>Catégorie introuvable</div>
 
  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>
  )
}

Créez 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">
        Tous les produits
      </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>
  )
}

Étape 7 : Fonctionnalité de recherche

Créez 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">
        Résultats pour &quot;{query}&quot;
      </h1>
 
      {loading && <p>Recherche en cours...</p>}
 
      {!loading && products.length === 0 && query && (
        <p className="text-gray-500">Aucun produit trouvé.</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>
  )
}

Étape 8 : Support multi-région

L'une des fonctionnalités phares de Medusa 2.0 est le support multi-région intégré. C'est idéal pour les boutiques MENA qui desservent plusieurs pays.

Configurer les régions

Dans l'admin Medusa (http://localhost:9000/app), créez des régions :

  1. Golfe — devise : SAR, pays : SA, AE, KW, BH, OM, QA
  2. Afrique du Nord — devise : TND, pays : TN, DZ, MA, LY, EG
  3. International — devise : USD, pays : US, GB, DE, FR

Détection de région dans Next.js

Créez 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]
}

Étape 9 : Déployer en production

Déployer le backend Medusa

Option A : Railway (Recommandé)

# Installer le CLI Railway
npm install -g @railway/cli
 
# Se connecter et déployer
railway login
railway init
railway add --plugin postgresql
railway add --plugin redis
 
# Définir les variables d'environnement
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"
 
# Déployer
railway up

Option B : Docker

Créez un Dockerfile :

FROM node:20-alpine
 
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
RUN npm run build
 
EXPOSE 9000
CMD ["npm", "start"]

Déployer la vitrine Next.js

# Déployer sur Vercel
npx vercel --prod
 
# Définir les variables d'environnement dans le tableau de bord Vercel
# NEXT_PUBLIC_MEDUSA_URL=https://your-medusa-backend.railway.app
# NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_live_xxx

Dépannage

Problèmes courants

Erreurs CORS lors de la connexion frontend-backend

Vérifiez que STORE_CORS dans votre config Medusa inclut l'URL de votre frontend :

storeCors: "http://localhost:3000,https://your-store.vercel.app"

Les produits n'affichent pas de prix

Assurez-vous de passer le region_id ou d'avoir une clé API publiable avec un canal de vente par défaut :

const { products } = await medusa.store.product.list({
  region_id: "reg_xxx",
  fields: "+variants.calculated_price",
})

Échec de la création du panier

Vérifiez que Redis fonctionne — Medusa 2.0 utilise Redis pour les sessions panier :

redis-cli ping  # Doit retourner PONG

Prochaines étapes

Maintenant que vous avez une boutique fonctionnelle, vous pouvez :

  • Ajouter les paiements Stripe — Installez @medusajs/payment-stripe pour le traitement réel des paiements
  • Configurer des webhooks — Gérez les événements de commande pour les notifications email
  • Construire un tableau de bord admin — Utilisez l'UI admin Medusa ou créez des pages personnalisées
  • Ajouter les avis produits — Créez un module personnalisé pour les évaluations
  • Implémenter une liste de souhaits — Utilisez les endpoints personnalisés pour stocker les favoris
  • Optimiser le SEO — Ajoutez les balises meta, les données structurées et les sitemaps

Conclusion

Vous avez construit une boutique e-commerce headless complète avec Medusa.js 2.0 et Next.js. L'architecture modulaire signifie que vous pouvez remplacer n'importe quel composant — utilisez Stripe à la place du fournisseur de paiement par défaut, ajoutez un module de livraison personnalisé, ou connectez un frontend complètement différent.

Medusa 2.0 est une alternative open-source sérieuse à Shopify pour les développeurs qui veulent un contrôle total sur leur stack commerce. La combinaison avec Next.js vous offre le rendu côté serveur, la génération statique, et une expérience développeur de classe mondiale.

Le code source complet de ce tutoriel est disponible comme point de départ — personnalisez-le pour votre marché, ajoutez votre identité visuelle, et lancez votre boutique.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur Construire un chatbot RAG avec Supabase pgvector et Next.js.

Discutez de votre projet avec nous

Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.

Trouvons les meilleures solutions pour vos besoins.

Articles connexes

Construire une API GraphQL typesafe avec Next.js App Router, Yoga et Pothos

Apprenez à construire une API GraphQL entièrement typesafe avec Next.js 15 App Router, GraphQL Yoga et le constructeur de schémas Pothos. Ce tutoriel pratique couvre la conception de schémas, les requêtes, les mutations, le middleware d'authentification et un client React avec urql.

30 min read·