Building an E-Commerce Store with Next.js 15 and Stripe
Learn how to build a production-ready e-commerce store using Next.js 15 App Router and Stripe in 2026. This tutorial covers product catalogues, Stripe Checkout, webhooks, order management, Zustand cart state, optimistic updates, and SEO for product pages.
Building an E-Commerce Store with Next.js 15 and Stripe
Author: Elhassane Mehdioui, Full Stack Web Developer Published: June 15, 2026
If you have been looking for a complete guide to building a modern Next.js e-commerce Stripe integration in 2026, you are in the right place. This tutorial walks you through every layer of a production-ready store: product catalogues powered by the App Router, a cart managed with Zustand, Stripe Checkout and the Customer Portal, webhook handling for order fulfilment, optimistic UI updates, and technical SEO for product pages. By the end you will have a solid foundation you can ship.
1. Project Setup
Start with the official scaffolder and add the libraries you need.
npx create-next-app@latest my-store --typescript --tailwind --app
cd my-store
npm install stripe @stripe/stripe-js zustand zod
Create a .env.local file:
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_BASE_URL=http://localhost:3000
2. Product Catalogue with the App Router
The App Router makes it trivial to co-locate data fetching with rendering. Create a products route that fetches from your database (Postgres, Supabase, or a headless CMS) at the server level.
// app/products/page.tsx
import { db } from "@/lib/db";
import ProductCard from "@/components/ProductCard";
export const revalidate = 3600; // ISR — revalidate every hour
export default async function ProductsPage() {
const products = await db.product.findMany({ where: { active: true } });
return (
<main className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 p-8">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</main>
);
}
For individual product pages, generate static paths at build time and fall back to on-demand rendering for new items:
// app/products/[slug]/page.tsx
import { db } from "@/lib/db";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
export async function generateStaticParams() {
const products = await db.product.findMany({ select: { slug: true } });
return products.map((p) => ({ slug: p.slug }));
}
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
const product = await db.product.findUnique({ where: { slug: params.slug } });
if (!product) return {};
return {
title: `${product.name} | My Store`,
description: product.description.slice(0, 160),
openGraph: {
images: [{ url: product.imageUrl, width: 1200, height: 630 }],
},
};
}
export default async function ProductPage({ params }: { params: { slug: string } }) {
const product = await db.product.findUnique({ where: { slug: params.slug } });
if (!product) notFound();
return <ProductDetail product={product} />;
}
The generateMetadata function is the key to SEO for product pages in Next.js 15. Each product gets its own canonical title, description, and Open Graph image without any extra client-side work.
3. Cart State with Zustand
Zustand is the lightest way to manage a shopping cart. Keep it in a persistent store so the cart survives page refreshes.
// store/cartStore.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
imageUrl: string;
}
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clear: () => 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 + 1 } : i
),
};
}
return { items: [...state.items, { ...item, quantity: 1 }] };
}),
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)),
})),
clear: () => set({ items: [] }),
total: () =>
get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
}),
{ name: "cart-storage" }
)
);
4. Optimistic Updates When Adding to Cart
React 19's useOptimistic pairs perfectly with server actions to give instant feedback.
// components/AddToCartButton.tsx
"use client";
import { useOptimistic, useTransition } from "react";
import { useCartStore } from "@/store/cartStore";
export default function AddToCartButton({ product }: { product: Product }) {
const addItem = useCartStore((s) => s.addItem);
const [optimisticAdded, setOptimisticAdded] = useOptimistic(false);
const [, startTransition] = useTransition();
const handleClick = () => {
startTransition(async () => {
setOptimisticAdded(true);
addItem(product);
// Optionally sync to server-side cart here
});
};
return (
<button onClick={handleClick} className="btn-primary">
{optimisticAdded ? "Added!" : "Add to Cart"}
</button>
);
}
The button text changes immediately — no waiting for a network round-trip.
5. Stripe Checkout Integration
Create a server action (or Route Handler) that builds a Stripe Checkout Session from the cart contents.
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const { items } = await req.json();
const line_items = items.map((item: CartItem) => ({
price_data: {
currency: "usd",
product_data: { name: item.name, images: [item.imageUrl] },
unit_amount: Math.round(item.price * 100),
},
quantity: item.quantity,
}));
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items,
success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/order/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/cart`,
shipping_address_collection: { allowed_countries: ["US", "GB", "FR"] },
metadata: { source: "web" },
});
return NextResponse.json({ url: session.url });
}
On the client, redirect to Stripe:
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: useCartStore.getState().items }),
});
const { url } = await res.json();
window.location.href = url;
This is the core of any Stripe Checkout Next.js implementation — keep the secret key server-side, always.
6. Webhook Handling and Order Management
Webhooks are how Stripe tells your server that a payment actually succeeded. Never fulfil an order without verifying the webhook signature.
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { db } from "@/lib/db";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const body = await req.text();
const sig = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
await db.order.create({
data: {
stripeSessionId: session.id,
customerEmail: session.customer_details?.email ?? "",
amountTotal: (session.amount_total ?? 0) / 100,
status: "paid",
lineItems: JSON.stringify(
await stripe.checkout.sessions.listLineItems(session.id)
),
},
});
}
return NextResponse.json({ received: true });
}
// Disable body parsing — Stripe needs the raw body for signature verification
export const config = { api: { bodyParser: false } };
Register the webhook endpoint in the Stripe Dashboard pointing to https://yourdomain.com/api/webhooks/stripe. During local development, use the Stripe CLI:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
7. Stripe Customer Portal
Give returning customers self-service access to their billing history and subscription management.
// app/api/customer-portal/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const { customerId } = await req.json();
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_BASE_URL}/account`,
});
return NextResponse.json({ url: portalSession.url });
}
Store the Stripe customerId against the user record when the first checkout.session.completed event fires. From that point on, any authenticated user can be redirected to their portal with a single API call.
8. SEO for Product Pages
Beyond generateMetadata, add structured data (JSON-LD) directly in the product page component so search engines can display rich results.
// components/ProductJsonLd.tsx
export default function ProductJsonLd({ product }: { product: Product }) {
const schema = {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
description: product.description,
image: product.imageUrl,
offers: {
"@type": "Offer",
priceCurrency: "USD",
price: product.price,
availability: product.stock > 0
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
},
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
);
}
Include <ProductJsonLd product={product} /> at the top of your product page. This enables Google Shopping rich snippets — price, availability, and star ratings appear directly in search results without any additional configuration.
Also add a sitemap.ts at the root of your app directory so Google can discover every product URL automatically:
// app/sitemap.ts
import { db } from "@/lib/db";
import type { MetadataRoute } from "next";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const products = await db.product.findMany({ select: { slug: true, updatedAt: true } });
return products.map((p) => ({
url: `${process.env.NEXT_PUBLIC_BASE_URL}/products/${p.slug}`,
lastModified: p.updatedAt,
changeFrequency: "weekly",
priority: 0.8,
}));
}
9. Deployment
Deploy to Vercel for the best Next.js 15 experience. A few things to confirm before going live:
- Environment variables — add all four
.env.localkeys in the Vercel dashboard under Project Settings > Environment Variables. - Webhook endpoint — update the Stripe Dashboard to point to your production URL.
- Edge-compatible database — if you use Prisma, switch to
@prisma/adapter-neonorprisma-edgeso serverless functions cold-start quickly. - Image optimisation — add your CDN domain to
next.config.ts:
// next.config.ts
const nextConfig = {
images: {
remotePatterns: [{ protocol: "https", hostname: "cdn.yourstore.com" }],
},
};
export default nextConfig;
- Rate limiting — protect
/api/checkoutwith an edge middleware rate limiter (Upstash Redis +@upstash/ratelimit) to prevent abuse.
10. What to Build Next
This foundation covers the essentials of any build e-commerce React 2026 project. From here you can add:
- Subscriptions — switch
modein the Checkout Session to"subscription"and add Stripe Price IDs. - Inventory management — decrement stock atomically in the webhook handler inside a database transaction.
- Multi-currency — Stripe automatically presents prices in the buyer's local currency when you enable Adaptive Pricing.
- Analytics — pipe
checkout.session.completedevents to PostHog or Plausible for conversion tracking. - Reviews — add a
Reviewmodel, surface aggregate ratings in the JSON-LD schema.
Conclusion
Building a Next.js Stripe tutorial-quality store in 2026 is far more approachable than it used to be. The App Router handles data fetching and SEO at the framework level, Zustand keeps cart logic clean without boilerplate, and Stripe abstracts away every complexity of payment processing — from PCI compliance to fraud prevention. Wire them together with the patterns above and you have a store that is fast, secure, and ready to scale.
If you have questions or want to share what you built, reach out on GitHub or LinkedIn — I would love to see it.
Written by Elhassane Mehdioui, Full Stack Web Developer specialising in Next.js, React, and modern web architecture.