Next.js E-Commerce: Product Catalog, Cart, Checkout
Build a production e-commerce platform with Next.js 15 in 2026 — product catalog with search and filtering, server-side cart with cookie sessions, Stripe checko
Next.js E-Commerce: Product Catalog, Cart, Checkout, and Inventory Management
Quick answer. Build e-commerce on Next.js 15's App Router using Server Components for fast product pages, Server Actions for cart mutations, and Stripe for checkout. Model products with a price_cents integer, image and tag arrays, an inventory count, and a generated tsvector column for full-text search, scaling from MVP to millions of products.
Building e-commerce on Next.js 15 with the App Router gives you server components for fast product pages, server actions for cart mutations, and Stripe for payments — a stack that scales from MVP to millions of products without re-architecture.
Data Model
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
price_cents INTEGER NOT NULL,
images TEXT[] NOT NULL DEFAULT '{}',
category TEXT NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}',
inventory INTEGER NOT NULL DEFAULT 0,
-- Full-text search
search_vector TSVECTOR GENERATED ALWAYS AS (
to_tsvector('english', name || ' ' || coalesce(description, ''))
) STORED,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_products_category ON products(category);
CREATE INDEX idx_products_fts ON products USING GIN(search_vector);
CREATE INDEX idx_products_tags ON products USING GIN(tags);
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'PENDING',
subtotal_cents INTEGER NOT NULL,
tax_cents INTEGER NOT NULL DEFAULT 0,
total_cents INTEGER NOT NULL,
stripe_pi_id TEXT UNIQUE,
shipping_address JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES orders(id),
product_id UUID NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL,
unit_price_cents INTEGER NOT NULL, -- Snapshot price at purchase time
PRIMARY KEY (id)
);
Product Catalog with Search and Filtering
// app/products/page.tsx — Server Component
import { db } from '@/lib/db';
interface ProductsSearchParams {
category?: string;
q?: string;
minPrice?: string;
maxPrice?: string;
sort?: 'price_asc' | 'price_desc' | 'newest';
page?: string;
}
export default async function ProductsPage({
searchParams,
}: {
searchParams: ProductsSearchParams;
}) {
const page = Number(searchParams.page ?? 1);
const perPage = 24;
const products = await searchProducts({
query: searchParams.q,
category: searchParams.category,
minPriceCents: searchParams.minPrice ? Number(searchParams.minPrice) * 100 : undefined,
maxPriceCents: searchParams.maxPrice ? Number(searchParams.maxPrice) * 100 : undefined,
sort: searchParams.sort ?? 'newest',
page,
perPage,
});
return (
<div className="grid grid-cols-4 gap-6">
<ProductFilters /> {/* Client component — has onChange handlers */}
<div className="col-span-3">
<ProductGrid products={products} /> {/* Server component */}
<Pagination page={page} total={products.total} perPage={perPage} />
</div>
</div>
);
}
async function searchProducts(opts: {
query?: string;
category?: string;
minPriceCents?: number;
maxPriceCents?: number;
sort: string;
page: number;
perPage: number;
}) {
const { query, category, minPriceCents, maxPriceCents, sort, page, perPage } = opts;
const offset = (page - 1) * perPage;
const whereConditions: string[] = ['inventory > 0'];
const params: unknown[] = [];
let paramIdx = 1;
if (query) {
whereConditions.push(`search_vector @@ plainto_tsquery('english', $${paramIdx++})`);
params.push(query);
}
if (category) {
whereConditions.push(`category = $${paramIdx++}`);
params.push(category);
}
if (minPriceCents !== undefined) {
whereConditions.push(`price_cents >= $${paramIdx++}`);
params.push(minPriceCents);
}
if (maxPriceCents !== undefined) {
whereConditions.push(`price_cents <= $${paramIdx++}`);
params.push(maxPriceCents);
}
const orderBy = {
price_asc: 'price_cents ASC',
price_desc: 'price_cents DESC',
newest: 'created_at DESC',
}[sort] ?? 'created_at DESC';
const where = whereConditions.join(' AND ');
const [rows, countResult] = await Promise.all([
db.$queryRawUnsafe<Product[]>(
`SELECT * FROM products WHERE ${where} ORDER BY ${orderBy} LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`,
...params, perPage, offset,
),
db.$queryRawUnsafe<[{ count: string }]>(
`SELECT COUNT(*) FROM products WHERE ${where}`,
...params,
),
]);
return { items: rows, total: Number(countResult[0].count) };
}
🌐 Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 1000+ projects.
- React, Next.js, Node.js, TypeScript — production-grade stack
- Fixed-price contracts — no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Cart with Cookie-Based Sessions
Server-side cart stored in a signed cookie — no database required for anonymous carts:
// lib/cart.ts
import { cookies } from 'next/headers';
import { SignJWT, jwtVerify } from 'jose';
interface CartItem {
productId: string;
quantity: number;
}
interface Cart {
items: CartItem[];
}
const CART_SECRET = new TextEncoder().encode(process.env.CART_SECRET!);
const CART_COOKIE = 'cart';
export async function getCart(): Promise<Cart> {
const cookieStore = await cookies();
const token = cookieStore.get(CART_COOKIE)?.value;
if (!token) return { items: [] };
try {
const { payload } = await jwtVerify(token, CART_SECRET);
return payload as unknown as Cart;
} catch {
return { items: [] };
}
}
async function saveCart(cart: Cart): Promise<void> {
const token = await new SignJWT(cart as any)
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('7d')
.sign(CART_SECRET);
const cookieStore = await cookies();
cookieStore.set(CART_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60, // 7 days
path: '/',
});
}
// Server Actions for cart mutations
export async function addToCart(productId: string, quantity: number = 1): Promise<void> {
'use server';
const cart = await getCart();
const existing = cart.items.find(i => i.productId === productId);
if (existing) {
existing.quantity += quantity;
} else {
cart.items.push({ productId, quantity });
}
await saveCart(cart);
revalidatePath('/cart');
}
export async function removeFromCart(productId: string): Promise<void> {
'use server';
const cart = await getCart();
cart.items = cart.items.filter(i => i.productId !== productId);
await saveCart(cart);
revalidatePath('/cart');
}
export async function updateCartQuantity(productId: string, quantity: number): Promise<void> {
'use server';
const cart = await getCart();
if (quantity <= 0) {
cart.items = cart.items.filter(i => i.productId !== productId);
} else {
const item = cart.items.find(i => i.productId === productId);
if (item) item.quantity = quantity;
}
await saveCart(cart);
revalidatePath('/cart');
}
Checkout with Stripe
// app/checkout/actions.ts
'use server';
import Stripe from 'stripe';
import { getCart } from '@/lib/cart';
import { db } from '@/lib/db';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function createCheckoutSession(): Promise<{ url: string }> {
const cart = await getCart();
if (cart.items.length === 0) throw new Error('Cart is empty');
// Fetch current prices and validate inventory
const productIds = cart.items.map(i => i.productId);
const products = await db.products.findMany({
where: { id: { in: productIds } },
});
const lineItems: Stripe.Checkout.SessionCreateParams.LineItem[] = [];
for (const cartItem of cart.items) {
const product = products.find(p => p.id === cartItem.productId);
if (!product) throw new Error(`Product not found: ${cartItem.productId}`);
if (product.inventory < cartItem.quantity) {
throw new Error(`Insufficient inventory for ${product.name}`);
}
lineItems.push({
price_data: {
currency: 'usd',
unit_amount: product.priceCents,
product_data: {
name: product.name,
images: product.images.slice(0, 1),
},
},
quantity: cartItem.quantity,
});
}
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: lineItems,
success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/orders/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/cart`,
payment_intent_data: {
metadata: {
cartItems: JSON.stringify(cart.items.map(i => ({
productId: i.productId,
quantity: i.quantity,
}))),
},
},
});
return { url: session.url! };
}

🚀 Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4–8 weeks, full platforms in 3–5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Inventory Management with Optimistic Locking
The race condition to prevent: two customers buy the last item simultaneously. Optimistic locking with WHERE inventory >= qty prevents overselling:
// lib/inventory.ts
export async function reserveInventory(
items: Array<{ productId: string; quantity: number }>,
): Promise<void> {
// Use a transaction to atomically decrement all inventory
await db.$transaction(async (tx) => {
for (const { productId, quantity } of items) {
// Optimistic locking: update ONLY if sufficient inventory exists
const result = await tx.$executeRaw`
UPDATE products
SET inventory = inventory - ${quantity}
WHERE id = ${productId}
AND inventory >= ${quantity}
`;
if (result === 0) {
// No rows updated = insufficient inventory
const product = await tx.products.findUnique({
where: { id: productId },
select: { name: true, inventory: true },
});
throw new Error(
`Insufficient inventory for ${product?.name ?? productId}: ` +
`${product?.inventory ?? 0} available, ${quantity} requested`
);
}
}
});
}
Stripe Webhook: Fulfillment on Payment
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { headers } from 'next/headers';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text();
const sig = (await headers()).get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response('Invalid signature', { status: 400 });
}
if (event.type === 'payment_intent.succeeded') {
const pi = event.data.object as Stripe.PaymentIntent;
const cartItems = JSON.parse(pi.metadata.cartItems ?? '[]') as Array<{
productId: string;
quantity: number;
}>;
await db.$transaction(async (tx) => {
// Create order record
const order = await tx.orders.create({
data: {
status: 'CONFIRMED',
subtotalCents: pi.amount,
totalCents: pi.amount,
stripepiId: pi.id,
items: {
create: cartItems.map(item => ({
productId: item.productId,
quantity: item.quantity,
unitPriceCents: 0, // TODO: look up price at time of purchase
})),
},
},
});
// Reserve inventory atomically
await reserveInventory(cartItems);
// Trigger fulfillment
await sendOrderConfirmationEmail(order.id);
});
}
return new Response('OK', { status: 200 });
}
The Viprasol Method
We build production e-commerce platforms on Next.js — product catalog architecture, cart systems, Stripe integration, inventory management, and order processing pipelines. We've launched multiple commerce platforms across retail and B2B verticals.
→ Talk to our team about e-commerce product development.
Related Reading
- React Server Components — server components for product pages
- Payment Reconciliation — reconciling Stripe payments
- Database Indexing — full-text search with GIN indexes
- Distributed Systems Patterns — inventory reservation patterns
- Web Development Services — e-commerce development
Building a Next.js Product Category Page That Converts
A well-structured Next.js product category page is the backbone of any storefront, sitting between your homepage and individual product detail pages. With the App Router, each category becomes a dynamic route that fetches its product list on the server, so shoppers and search engines receive fully rendered HTML on first load. Pair this with incremental static regeneration to keep category listings fast even as inventory changes.
Inside each category, plan for filtering, sorting, and pagination from day one. Faceted filters by price, size, or brand should update the URL with query parameters, keeping every filtered view shareable and crawlable. Generate clean canonical tags and breadcrumb structured data so Google understands your category hierarchy.
Our senior engineers build category, catalog, and checkout flows you fully own, with no lock-in and clean, maintainable code throughout.
External Resources
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.
Need a Modern Web Application?
From landing pages to complex SaaS platforms — we build it all with Next.js and React.
Free consultation • No commitment • Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.