E-Commerce Architecture Best Practices in 2026
A technical guide to building scalable, production-grade e-commerce platforms, covering cart state, payment processing, inventory management, and performance.

Building an e-commerce platform that handles real traffic seasonal spikes, concurrent checkouts, and global users, requires deliberate architectural decisions from day one. Retrofitting scalability is expensive. This guide covers the core patterns we use at Codolve when building production e-commerce systems.
The Five Layers of a Production E-Commerce Stack
Before writing a line of code, understand the system's distinct layers:
- Storefront, the Next.js frontend: product pages, search, cart, checkout flow
- Catalogue, headless CMS or PIM for product data, descriptions, and media
- Cart and session, ephemeral state that needs speed, not durability (Redis)
- Order management, persistent, transactional (PostgreSQL with row-level locking)
- Payments, abstracted behind your own API, never handled directly
Each layer has different requirements. Conflating them leads to systems that are simultaneously too slow and too brittle.
Product Pages: Cache Aggressively, Personalise at the Edge
Product pages are your highest-traffic pages and the ones where performance directly impacts conversion rate. Amazon's research showed a 100ms increase in page load time reduced sales by 1%. That's not academic, it's measurable in your revenue.
Use Incremental Static Regeneration
Product pages are largely static, the name, description, and images change rarely. Price and inventory change more often. Use ISR to serve statically cached pages that regenerate on a schedule:
// app/products/[slug]/page.tsx
export const revalidate = 300; // Regenerate every 5 minutes
export async function generateStaticParams() {
const products = await catalogue.getAllProductSlugs();
return products.map((slug) => ({ slug }));
}
export default async function ProductPage({ params }: { params: { slug: string } }) {
const product = await catalogue.getProduct(params.slug);
return (
<div>
<ProductDetails product={product} />
{/* Price and stock fetched client-side for real-time accuracy */}
<PriceAndStock productId={product.id} />
</div>
);
}
The static shell loads in milliseconds. Real-time price and inventory are fetched client-side from a fast API endpoint, users see the cached page immediately, then the live data populates.
Personalise at the Edge Without Losing Cache Efficiency
Geographic pricing, promotional banners, and personalised recommendations don't require bypassing your cache. Handle personalisation in edge middleware before serving the cached response:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const country = request.geo?.country ?? "US";
const currency = getCurrencyForCountry(country);
const response = NextResponse.next();
response.cookies.set("currency", currency, { path: "/" });
return response;
}
The currency cookie drives client-side currency formatting, the cached page stays cached, personalisation happens at the edge in under 1ms.
Cart State: Redis Over Database
Never store cart state in a relational database table with row-level locking, it won't scale under concurrent write pressure. Redis handles cart operations efficiently with atomic commands:
// lib/cart.ts
import { redis } from "./redis";
const CART_TTL = 60 * 60 * 24 * 7; // 7 days
export async function getCart(
sessionId: string,
): Promise<Record<string, number>> {
const cart = await redis.hgetall(`cart:${sessionId}`);
return Object.fromEntries(
Object.entries(cart ?? {}).map(([k, v]) => [k, parseInt(v)]),
);
}
export async function addToCart(
sessionId: string,
productId: string,
qty: number,
) {
const key = `cart:${sessionId}`;
await redis.hincrby(key, productId, qty);
await redis.expire(key, CART_TTL);
}
export async function removeFromCart(sessionId: string, productId: string) {
await redis.hdel(`cart:${sessionId}`, productId);
}
export async function clearCart(sessionId: string) {
await redis.del(`cart:${sessionId}`);
}
Redis HINCRBY is atomic, two simultaneous "add to cart" operations on the same session won't corrupt each other. A Redis cart operation completes in under 1ms.
Payment Processing: Stripe Best Practices
The cardinal rule: never handle raw card data yourself. Stripe's PCI-compliant client libraries (Stripe.js, Stripe Elements, Payment Element) handle the sensitive capture in an isolated iframe. Your code only ever sees a paymentMethod or paymentIntent ID.
Create Payment Intents Server-Side
// app/api/checkout/route.ts
import Stripe from "stripe";
import { getServerSession } from "next-auth";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const session = await getServerSession();
if (!session?.user) {
return Response.json({ error: "Unauthorised" }, { status: 401 });
}
const { cartId } = await request.json();
const cart = await getCartTotal(cartId); // Calculate total server-side, never trust client
const paymentIntent = await stripe.paymentIntents.create({
amount: cart.totalCents, // Always in smallest currency unit (pence, cents)
currency: cart.currency,
metadata: {
cartId,
userId: session.user.id,
},
automatic_payment_methods: { enabled: true },
});
return Response.json({ clientSecret: paymentIntent.client_secret });
}
The total is calculated server-side from the cart ID. A malicious client cannot send a manipulated total, the server calculates the authoritative amount.
Handle Fulfilment Via Webhooks, Not Redirects
Never fulfil an order based on the client-side payment success redirect. Users close browsers, connections drop, JavaScript errors happen. Webhooks are the reliable path:
// 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(request: Request) {
const body = await request.text();
const signature = (await headers()).get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return Response.json({ error: "Invalid signature" }, { status: 400 });
}
if (event.type === "payment_intent.succeeded") {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
await fulfillOrder(paymentIntent.metadata.cartId);
await clearCart(paymentIntent.metadata.cartId);
await sendOrderConfirmationEmail(paymentIntent.metadata.userId);
}
return Response.json({ received: true });
}
Webhooks are retried by Stripe for up to 72 hours on failure. Your fulfilment logic runs reliably regardless of what happens in the browser.
Inventory Management: Prevent Overselling
Race conditions at checkout are a silent revenue killer. Two users on the "last item in stock" page can both add to cart, both proceed to checkout, and both complete payment, leaving you with a negative inventory count and an unhappy customer.
Atomic Inventory Reservation
Use a database transaction with a conditional update to atomically reserve stock:
// lib/inventory.ts
import { db } from "./db";
export async function reserveInventory(
productId: string,
qty: number,
): Promise<boolean> {
const result = await db.$executeRaw`
UPDATE products
SET reserved_count = reserved_count + ${qty}
WHERE id = ${productId}
AND (stock_count - reserved_count) >= ${qty}
`;
return result > 0; // Returns false if insufficient stock
}
export async function commitInventory(productId: string, qty: number) {
await db.product.update({
where: { id: productId },
data: {
stock_count: { decrement: qty },
reserved_count: { decrement: qty },
},
});
}
export async function releaseReservation(productId: string, qty: number) {
await db.product.update({
where: { id: productId },
data: { reserved_count: { decrement: qty } },
});
}
Reserve inventory when the user begins checkout. Release reservations when sessions expire or payments fail. Commit when payment succeeds.
Search: Algolia or Typesense for Production Scale
PostgreSQL full-text search struggles at scale and lacks features like typo tolerance, faceted filtering, and relevance tuning that users expect from e-commerce search.
For any site with more than a few hundred products, a dedicated search service is worth the investment:
// lib/search.ts
import { SearchClient } from "algoliasearch";
const client = new SearchClient(
process.env.ALGOLIA_APP_ID!,
process.env.ALGOLIA_SEARCH_KEY!, // Read-only key, safe to expose
);
export async function searchProducts(query: string, filters: string) {
const { hits } = await client.searchSingleIndex({
indexName: "products",
searchParams: {
query,
filters,
hitsPerPage: 24,
attributesToHighlight: ["name", "description"],
},
});
return hits;
}
Sync your product catalogue to Algolia/Typesense on write using database webhooks or event-driven updates.
Monitoring: What to Track in Production
A production e-commerce system without monitoring is flying blind:
- Checkout conversion rate: baseline it before any change, track it after
- Cart abandonment rate: signals friction in your checkout flow
- Inventory reservation failures: spikes indicate stockout issues or bot attacks
- Payment intent success rate: Stripe's dashboard shows this; alerts on drops
- API response times: p95 and p99 latency for cart and checkout endpoints
Set alerts for anomalies. A checkout API that suddenly degrades from 100ms to 2000ms should page someone immediately.
Frequently Asked Questions
Should I build my own e-commerce platform or use a headless solution?
For most businesses, a headless CMS + Shopify/Medusa for commerce + Next.js for the storefront is faster to market and cheaper to maintain than a fully custom build. Build custom only when your requirements genuinely can't be met by existing solutions.
How should I handle multi-currency pricing?
Store all prices in your base currency (e.g., GBP in pence). Convert to display currencies at render time using exchange rates fetched from a reliable API. Never let users pay in a converted currency unless your payment provider supports multi-currency settlement (Stripe does).
Is Redis reliable enough for cart state?
Yes, with proper persistence configuration. Enable Redis AOF (Append Only File) persistence for durability. In high-availability setups, use Redis Sentinel or Redis Cluster. For managed options, Upstash Redis and Redis Cloud handle persistence and HA automatically.
How do I handle checkout during a flash sale with massive traffic?
Queue checkouts. Instead of processing payments synchronously, accept the request, enqueue a checkout job, and respond immediately with a "processing" status. A worker processes the queue at a controlled rate. This prevents your payment processor from being overwhelmed and gives you backpressure control.
What's the recommended stack for a new e-commerce project?
Next.js (storefront) + Medusa or Shopify (commerce engine) + PostgreSQL (orders) + Redis (cart/sessions) + Stripe (payments) + Algolia (search) + Cloudinary or similar (media). This stack is proven, well-documented, and scales from startup to enterprise.
Tags





