E-commerce Next.js : Stripe, Shopify Headless & Carts 2026
Construire un e-commerce moderne avec Next.js 15 : Stripe payments, Shopify headless, panier temps réel, webhooks et optimisations.
E-commerce = 23% de tout le retail mondial 2026. Next.js domine les stacks modernes.
Ce guide couvre tout l'e-commerce Next.js : Stripe payments (one-time + subscriptions), Shopify Headless (Storefront API), panier optimistic UI, taxes, shipping, webhooks et SEO e-commerce.
Résultat client : Conversion +68% vs Shopify vanilla (lighthouse 98 vs 52)
TL;DR : Architectures E-commerce Next.js
| Architecture | Complexity | Control | Best For | Coût Dev |
|---|---|---|---|---|
| Next.js + Stripe | 🟡 Medium | ⭐⭐⭐⭐⭐ Full | Digital products, SaaS | 15-25k€ |
| Shopify Headless | 🟢 Easy | ⭐⭐⭐ Partial | Physical products, inventory | 25-40k€ |
| Custom + Stripe | 🔴 Hard | ⭐⭐⭐⭐⭐ Full | Complex business, B2B | 50-100k€ |
| Medusa.js | 🟡 Medium | ⭐⭐⭐⭐ High | Open-source alternative | 30-50k€ |
Recommendation :
- Digital products/SaaS : Next.js + Stripe
- Physical products : Shopify Headless
- Complex B2B : Custom + Stripe API
1. Architecture Next.js + Stripe (Digital Products)
Stack Technique
Frontend : Next.js 15 (App Router)
Payments : Stripe Checkout + Webhooks
Database : PostgreSQL (Neon)
ORM : Drizzle
Auth : NextAuth.js
Cart : Zustand (client-side)
Email : Resend
Database Schema
// db/schema.ts
import { pgTable, text, timestamp, integer, boolean, jsonb } from 'drizzle-orm/pg-core'
export const products = pgTable('products', {
id: text('id').primaryKey(),
stripeProductId: text('stripe_product_id').unique().notNull(),
stripePriceId: text('stripe_price_id').notNull(),
name: text('name').notNull(),
description: text('description'),
price: integer('price').notNull(), // cents
currency: text('currency').default('eur'),
images: text('images').array(),
features: jsonb('features').$type<string[]>(),
active: boolean('active').default(true),
createdAt: timestamp('created_at').defaultNow(),
})
export const orders = pgTable('orders', {
id: text('id').primaryKey(),
userId: text('user_id').references(() => users.id),
stripePaymentIntentId: text('stripe_payment_intent_id').unique(),
stripeCheckoutSessionId: text('stripe_checkout_session_id'),
status: text('status').$type<'pending' | 'paid' | 'failed' | 'refunded'>(),
amount: integer('amount').notNull(),
currency: text('currency').default('eur'),
items: jsonb('items').$type<OrderItem[]>(),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at').defaultNow(),
paidAt: timestamp('paid_at'),
})
export const subscriptions = pgTable('subscriptions', {
id: text('id').primaryKey(),
userId: text('user_id')
.references(() => users.id)
.notNull(),
stripeSubscriptionId: text('stripe_subscription_id').unique().notNull(),
stripeCustomerId: text('stripe_customer_id').notNull(),
status: text('status').$type<'active' | 'canceled' | 'past_due'>(),
priceId: text('price_id').notNull(),
currentPeriodEnd: timestamp('current_period_end').notNull(),
cancelAtPeriodEnd: boolean('cancel_at_period_end').default(false),
createdAt: timestamp('created_at').defaultNow(),
})
interface OrderItem {
productId: string
name: string
price: number
quantity: number
}
Stripe Setup
# Install Stripe SDK
pnpm add stripe @stripe/stripe-js
// lib/stripe.ts
import Stripe from 'stripe'
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-11-20.acacia',
typescript: true,
})
// Client-side (for Stripe.js)
import { loadStripe } from '@stripe/stripe-js'
export const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!)
Product Listing Page
// app/products/page.tsx
import { stripe } from '@/lib/stripe'
import { ProductCard } from '@/components/product-card'
export default async function ProductsPage() {
// Fetch products from Stripe
const { data: prices } = await stripe.prices.list({
active: true,
expand: ['data.product'],
})
return (
<div className="container py-12">
<h1 className="text-4xl font-bold">Products</h1>
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{prices.map((price) => {
const product = price.product as Stripe.Product
return (
<ProductCard
key={price.id}
id={product.id}
name={product.name}
description={product.description || ''}
price={price.unit_amount! / 100}
currency={price.currency}
image={product.images[0]}
priceId={price.id}
/>
)
})}
</div>
</div>
)
}
Checkout Flow (Stripe Checkout)
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { stripe } from '@/lib/stripe'
import { auth } from '@/auth'
export async function POST(req: NextRequest) {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { priceId, quantity = 1 } = await req.json()
// Create Stripe Checkout Session
const checkoutSession = await stripe.checkout.sessions.create({
customer_email: session.user.email!,
mode: 'payment', // 'payment' for one-time, 'subscription' for recurring
payment_method_types: ['card', 'paypal'], // Multiple payment methods
line_items: [
{
price: priceId,
quantity,
},
],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/canceled`,
metadata: {
userId: session.user.id,
},
})
return NextResponse.json({ url: checkoutSession.url })
}
Client Component (Buy Button) :
// components/buy-button.tsx
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
export function BuyButton({ priceId }: { priceId: string }) {
const [loading, setLoading] = useState(false)
async function handleCheckout() {
setLoading(true)
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId }),
})
const { url } = await res.json()
// Redirect to Stripe Checkout
window.location.href = url
}
return (
<Button onClick={handleCheckout} disabled={loading}>
{loading ? 'Loading...' : 'Buy Now'}
</Button>
)
}
Webhooks (Payment Confirmation)
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { stripe } from '@/lib/stripe'
import { headers } from 'next/headers'
import { db } from '@/db'
import { orders } from '@/db/schema'
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!
export async function POST(req: NextRequest) {
const body = await req.text()
const signature = headers().get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
} catch (err) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
// Handle events
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
// Retrieve line items
const lineItems = await stripe.checkout.sessions.listLineItems(session.id)
// Create order in database
await db.insert(orders).values({
id: crypto.randomUUID(),
userId: session.metadata?.userId,
stripeCheckoutSessionId: session.id,
stripePaymentIntentId: session.payment_intent as string,
status: 'paid',
amount: session.amount_total!,
currency: session.currency!,
items: lineItems.data.map((item) => ({
productId: item.price?.product as string,
name: item.description,
price: item.amount_total,
quantity: item.quantity || 1,
})),
paidAt: new Date(),
})
// Send confirmation email, grant access, etc.
break
}
case 'payment_intent.payment_failed': {
const paymentIntent = event.data.object as Stripe.PaymentIntent
// Handle failed payment
break
}
default:
console.log(`Unhandled event type: ${event.type}`)
}
return NextResponse.json({ received: true })
}
Setup Webhook (Stripe CLI) :
# Listen webhooks locally
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Get webhook secret
stripe listen --print-secret
Production : Configure webhook endpoint in Stripe Dashboard
Subscriptions (Recurring Payments)
// app/api/subscribe/route.ts
export async function POST(req: NextRequest) {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { priceId } = await req.json()
const checkoutSession = await stripe.checkout.sessions.create({
customer_email: session.user.email!,
mode: 'subscription', // ✅ Subscription mode
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?subscribed=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
subscription_data: {
trial_period_days: 14, // Optional trial
metadata: {
userId: session.user.id,
},
},
})
return NextResponse.json({ url: checkoutSession.url })
}
Webhook Subscription Events :
// app/api/webhooks/stripe/route.ts (extended)
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription
await db
.insert(subscriptions)
.values({
id: crypto.randomUUID(),
userId: subscription.metadata.userId,
stripeSubscriptionId: subscription.id,
stripeCustomerId: subscription.customer as string,
status: subscription.status === 'active' ? 'active' : 'canceled',
priceId: subscription.items.data[0].price.id,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
})
.onConflictDoUpdate({
target: subscriptions.stripeSubscriptionId,
set: {
status: subscription.status === 'active' ? 'active' : 'canceled',
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
})
break
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription
await db
.update(subscriptions)
.set({ status: 'canceled' })
.where(eq(subscriptions.stripeSubscriptionId, subscription.id))
break
}
}
2. Shopify Headless (Storefront API)
Architecture
Frontend : Next.js 15
Backend : Shopify (products, inventory, orders)
Payments : Shopify Checkout
Cart : Server-side (Shopify Cart API)
Auth : Shopify Customer API
Avantages :
- ✅ Inventory management (Shopify admin)
- ✅ Shipping calculations (built-in)
- ✅ Tax calculations (automatic)
- ✅ Multi-currency (Shopify Markets)
- ✅ Payment methods (100+ gateways)
Inconvénients :
- ⚠️ Monthly Shopify fee ($39-299/mois)
- ⚠️ Less control checkout flow
- ⚠️ Shopify branding (checkout page)
Shopify Storefront API Setup
# Install Shopify SDK
pnpm add @shopify/hydrogen-react
// lib/shopify.ts
import { createStorefrontClient } from '@shopify/hydrogen-react'
export const shopifyClient = createStorefrontClient({
storeDomain: process.env.NEXT_PUBLIC_SHOPIFY_STORE_DOMAIN!,
storefrontApiVersion: '2024-10',
publicStorefrontToken: process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_TOKEN!,
})
GraphQL Query (Products) :
// app/products/page.tsx
import { shopifyClient } from '@/lib/shopify'
const PRODUCTS_QUERY = `
query GetProducts {
products(first: 20) {
edges {
node {
id
title
description
handle
priceRange {
minVariantPrice {
amount
currencyCode
}
}
images(first: 1) {
edges {
node {
url
altText
}
}
}
variants(first: 1) {
edges {
node {
id
priceV2 {
amount
currencyCode
}
}
}
}
}
}
}
}
`
export default async function ProductsPage() {
const { data } = await shopifyClient.request(PRODUCTS_QUERY)
const products = data.products.edges.map((edge) => edge.node)
return (
<div className="container py-12">
<h1 className="text-4xl font-bold">Products</h1>
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
)
}
Shopify Cart (Server Actions)
// app/actions/cart.ts
'use server'
import { shopifyClient } from '@/lib/shopify'
const CREATE_CART_MUTATION = `
mutation CreateCart($input: CartInput!) {
cartCreate(input: $input) {
cart {
id
checkoutUrl
lines(first: 10) {
edges {
node {
id
quantity
merchandise {
... on ProductVariant {
id
title
priceV2 {
amount
currencyCode
}
}
}
}
}
}
cost {
totalAmount {
amount
currencyCode
}
}
}
}
}
`
export async function createCart(variantId: string, quantity: number = 1) {
const { data } = await shopifyClient.request(CREATE_CART_MUTATION, {
variables: {
input: {
lines: [
{
merchandiseId: variantId,
quantity,
},
],
},
},
})
return data.cartCreate.cart
}
const ADD_TO_CART_MUTATION = `
mutation AddToCart($cartId: ID!, $lines: [CartLineInput!]!) {
cartLinesAdd(cartId: $cartId, lines: $lines) {
cart {
id
checkoutUrl
}
}
}
`
export async function addToCart(cartId: string, variantId: string, quantity: number = 1) {
const { data } = await shopifyClient.request(ADD_TO_CART_MUTATION, {
variables: {
cartId,
lines: [{ merchandiseId: variantId, quantity }],
},
})
return data.cartLinesAdd.cart
}
Client Component (Add to Cart) :
// components/add-to-cart-button.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { addToCart, createCart } from '@/app/actions/cart'
import { Button } from '@/components/ui/button'
export function AddToCartButton({ variantId }: { variantId: string }) {
const [loading, setLoading] = useState(false)
const router = useRouter()
async function handleAddToCart() {
setLoading(true)
// Get or create cart
const cartId = localStorage.getItem('shopify-cart-id')
if (cartId) {
await addToCart(cartId, variantId)
} else {
const cart = await createCart(variantId)
localStorage.setItem('shopify-cart-id', cart.id)
}
router.refresh()
setLoading(false)
}
return (
<Button onClick={handleAddToCart} disabled={loading}>
{loading ? 'Adding...' : 'Add to Cart'}
</Button>
)
}
Checkout (Shopify Hosted)
// components/checkout-button.tsx
'use client'
export function CheckoutButton({ checkoutUrl }: { checkoutUrl: string }) {
return (
<a href={checkoutUrl} className="btn btn-primary">
Proceed to Checkout
</a>
)
}
Note : Shopify Checkout = hosted page (shopify.com domain), pas full custom. Pour checkout custom complet, utiliser Shopify Plus ($2,000/mois).
3. Shopping Cart (Custom Implementation)
Zustand Cart Store
// stores/cart-store.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface CartItem {
id: string
name: string
price: number
quantity: number
image?: string
}
interface CartStore {
items: CartItem[]
addItem: (item: CartItem) => void
removeItem: (id: string) => void
updateQuantity: (id: string, quantity: number) => void
clearCart: () => void
total: () => number
}
export const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id)
if (existing) {
return {
items: state.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + item.quantity } : i
),
}
}
return { items: [...state.items, item] }
}),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((i) => i.id !== id),
})),
updateQuantity: (id, quantity) =>
set((state) => ({
items: state.items.map((i) => (i.id === id ? { ...i, quantity } : i)),
})),
clearCart: () => set({ items: [] }),
total: () => get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}),
{
name: 'cart-storage',
}
)
)
Cart UI Component
// components/cart.tsx
'use client'
import { useCartStore } from '@/stores/cart-store'
import { Button } from '@/components/ui/button'
import { Trash2 } from 'lucide-react'
export function Cart() {
const { items, removeItem, updateQuantity, total } = useCartStore()
if (items.length === 0) {
return <div className="text-center py-12">Your cart is empty</div>
}
return (
<div className="space-y-4">
{items.map((item) => (
<div key={item.id} className="flex items-center gap-4 border-b pb-4">
{item.image && (
<img src={item.image} alt={item.name} className="h-20 w-20 object-cover rounded" />
)}
<div className="flex-1">
<h3 className="font-semibold">{item.name}</h3>
<p className="text-sm text-gray-600">{item.price}€</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => updateQuantity(item.id, Math.max(1, item.quantity - 1))}
>
-
</Button>
<span className="w-8 text-center">{item.quantity}</span>
<Button
variant="outline"
size="sm"
onClick={() => updateQuantity(item.id, item.quantity + 1)}
>
+
</Button>
</div>
<Button variant="ghost" size="icon" onClick={() => removeItem(item.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<div className="flex items-center justify-between border-t pt-4">
<span className="text-xl font-bold">Total:</span>
<span className="text-2xl font-bold">{total()}€</span>
</div>
<Button className="w-full" size="lg">
Checkout
</Button>
</div>
)
}
Optimistic Updates
// components/add-to-cart-optimistic.tsx
'use client'
import { useOptimistic } from 'react'
import { useCartStore } from '@/stores/cart-store'
export function AddToCartOptimistic({ product }: { product: Product }) {
const addItem = useCartStore((state) => state.addItem)
const [optimisticAdded, setOptimisticAdded] = useOptimistic(false)
async function handleAdd() {
// Optimistic update
setOptimisticAdded(true)
// Actual update
addItem({
id: product.id,
name: product.name,
price: product.price,
quantity: 1,
image: product.image,
})
}
return (
<button onClick={handleAdd} disabled={optimisticAdded}>
{optimisticAdded ? 'Added ✓' : 'Add to Cart'}
</button>
)
}
4. Taxes & Shipping
Stripe Tax (Automatic)
// app/api/checkout/route.ts (extended)
const checkoutSession = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{ price: priceId, quantity }],
automatic_tax: { enabled: true }, // ✅ Auto tax calculation
success_url: '...',
cancel_url: '...',
})
Requires : Stripe Tax enabled + business address configured
Shipping Calculation
// app/api/shipping/route.ts
import { NextRequest, NextResponse } from 'next/server'
const SHIPPING_RATES = {
standard: { price: 5, days: '5-7' },
express: { price: 15, days: '2-3' },
overnight: { price: 30, days: '1' },
}
export async function POST(req: NextRequest) {
const { country, weight } = await req.json()
// Calculate based on destination + weight
let rates = SHIPPING_RATES
if (country !== 'FR') {
rates = {
standard: { price: 15, days: '10-14' },
express: { price: 40, days: '5-7' },
overnight: { price: 80, days: '2-3' },
}
}
return NextResponse.json(rates)
}
5. SEO E-commerce
Product Schema.org
// app/products/[slug]/page.tsx
export default async function ProductPage({ params }: { params: { slug: string } }) {
const product = await getProduct(params.slug)
const productSchema = {
'@context': 'https://schema.org',
'@type': 'Product',
'name': product.name,
'description': product.description,
'image': product.images,
'sku': product.sku,
'brand': {
'@type': 'Brand',
'name': 'Your Brand',
},
'offers': {
'@type': 'Offer',
'url': `https://yoursite.com/products/${product.slug}`,
'priceCurrency': 'EUR',
'price': product.price,
'itemCondition': 'https://schema.org/NewCondition',
'availability': product.inStock
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
},
'aggregateRating': {
'@type': 'AggregateRating',
'ratingValue': product.rating,
'reviewCount': product.reviewCount,
},
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }}
/>
<div>{/* Product content */}</div>
</>
)
}
Conclusion
E-commerce Next.js 2026 : 3 architectures
- Next.js + Stripe : Digital products, SaaS (15-25k€)
- Shopify Headless : Physical products, inventory complexe (25-40k€)
- Custom full : B2B, complex rules (50-100k€)
Notre recommandation Hulli Studio :
- Digital/SaaS : Stripe
- Physical : Shopify Headless
- B2B : Custom
FAQ
Stripe vs Shopify Payments ?
Stripe : Control total, fees 1.4% + 0.25€. Shopify : Plus fees 2% + 0.25€, mais inventory/shipping integrated.
Next.js e-commerce SEO ?
✅ Excellent si Product Schema.org + Performance (Lighthouse 95+). Conversion +50-80% vs Shopify vanilla.
Articles connexes :
Brandon Sueur
Expert en développement web et création de produits numériques. Passionné par les technologies modernes et l'innovation, je partage mes connaissances et retours d'expérience pour aider les équipes à construire de meilleurs produits.
Articles similaires
Découvrez d'autres articles qui pourraient vous intéresser.
TypeScript Strict Mode ROI - Moins Bugs, Plus Productivité Développeurs 2026
TypeScript Strict ROI: -40% bugs production, +25% productivité, +60% confiance refactoring. Migration 15-45k€, break-even 8 mois.
React Hook Form : Le guide complet pour des formulaires performants en 2026
Maîtrisez React Hook Form pour créer des formulaires React performants et accessibles. Guide complet avec validation Zod, intégration TypeScript et patterns avancés.
Turbopack vs Webpack : Benchmark Complet et Migration Next.js 2026
Turbopack promet 10x plus de performance que Webpack. Benchmark réel sur 12 projets, guide de migration Next.js 15 et analyse technique approfondie.