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

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.0 | Shopify | WooCommerce |
|---|---|---|---|
| Open-source | Oui | Non | Oui |
| Headless-first | Oui | Partiel | Plugin |
| Modules custom | Première classe | Limité | Plugins |
| Multi-région | Intégré | Coûteux | Manuel |
| TypeScript | Complet | Liquid | PHP |
| Auto-hébergé | Oui | Non | Oui |
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-cliCréer un nouveau projet Medusa
medusa new my-store --v2
cd my-storeCela génère un projet Medusa 2.0 contenant :
src/— vos modules personnalisés et routes APImedusa-config.ts— configuration centralepackage.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.tsLancer le backend
npx medusa developVotre 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-storefrontInstaller les dépendances
npm install @medusajs/js-sdk @medusajs/typesConfigurer 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_hereVous 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>
)
}Navigation par catégories
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 "{query}"
</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 :
- Golfe — devise : SAR, pays : SA, AE, KW, BH, OM, QA
- Afrique du Nord — devise : TND, pays : TN, DZ, MA, LY, EG
- 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 upOption 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_xxxDé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 PONGProchaines étapes
Maintenant que vous avez une boutique fonctionnelle, vous pouvez :
- Ajouter les paiements Stripe — Installez
@medusajs/payment-stripepour 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.
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

Strapi 5 et Next.js 15 : Créer une Application Full-Stack avec un CMS Headless
Apprenez à créer une application full-stack avec Strapi 5 comme CMS headless et Next.js 15 App Router. Ce tutoriel couvre la création de types de contenu, les API REST, le rendu côté serveur et le déploiement en production.

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.

Construire un site web axé sur le contenu avec Payload CMS 3 et Next.js
Apprenez à construire un site web complet avec Payload CMS 3, qui fonctionne nativement dans Next.js App Router. Ce tutoriel couvre les collections, le rich text, les uploads, l'authentification et le déploiement en production.