Back to Blog

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

Viprasol Tech Team
July 4, 2026
13 min read

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

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

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

Viprasol · Web Development

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.