Next.js E-Commerce: Product Catalog, Cart, Checkout, and Inventory Management
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
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 100+ 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 });
}
Working With Viprasol
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.
See Also
- 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
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 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
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.