E-commerce Next.js : Stripe, Shopify Headless & Carts 2026
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 :