E-commerce Next.js : Stripe, Shopify Headless & Carts 2026

Brandon Sueur20 min

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

  1. Next.js + Stripe : Digital products, SaaS (15-25k€)
  2. Shopify Headless : Physical products, inventory complexe (25-40k€)
  3. 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 :