E-commerce Catalog
Managing product catalog caching for an e-commerce platform
E-commerce Catalog Scenario
Learn how to implement caching for an e-commerce platform with product listings, inventory management, and shopping cart operations.
The Challenge
E-commerce has demanding caching requirements:
- Product pages: High-traffic, need aggressive caching
- Inventory: Must reflect stock accurately to prevent overselling
- Pricing: Must be consistent across all views
- Cart: User-specific, needs to persist across sessions
- Categories: Structural, cached but updated on reorganization
Schema Design
// lib/cache.ts
import { createCache } from 'next-cool-cache';
const schema = {
products: {
list: {},
featured: {},
byId: { _params: ['id'] as const },
bySlug: { _params: ['slug'] as const },
byCategory: { _params: ['categoryId'] as const },
byBrand: { _params: ['brandId'] as const },
search: { _params: ['query'] as const },
inventory: {
byId: { _params: ['productId'] as const },
},
pricing: {
byId: { _params: ['productId'] as const },
},
},
categories: {
list: {},
tree: {},
byId: { _params: ['id'] as const },
bySlug: { _params: ['slug'] as const },
},
brands: {
list: {},
byId: { _params: ['id'] as const },
bySlug: { _params: ['slug'] as const },
},
cart: {
bySessionId: { _params: ['sessionId'] as const },
byUserId: { _params: ['userId'] as const },
},
wishlist: {
byUserId: { _params: ['userId'] as const },
},
reviews: {
byProductId: { _params: ['productId'] as const },
average: { _params: ['productId'] as const },
},
} as const;
const scopes = ['admin', 'public', 'customer'] as const;
export const cache = createCache(schema, scopes);Implementation Patterns
Pattern 1: Product Listing Page
// app/products/page.tsx
import { cache } from '@/lib/cache';
async function getProductsList(page: number = 1) {
'use cache: remote';
cache.public.products.list.cacheTag();
return db.products.findMany({
where: { status: 'published', inventory: { gt: 0 } },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * 24,
take: 24,
include: {
category: true,
images: { take: 1 },
},
});
}
async function getFeaturedProducts() {
'use cache: remote';
cache.public.products.featured.cacheTag();
return db.products.findMany({
where: { featured: true, status: 'published' },
take: 8,
include: { images: { take: 1 } },
});
}
export default async function ProductsPage() {
const [products, featured] = await Promise.all([
getProductsList(),
getFeaturedProducts(),
]);
return (
<ShopLayout>
<FeaturedCarousel products={featured} />
<ProductGrid products={products} />
</ShopLayout>
);
}Pattern 2: Product Detail Page
Product pages need separate caching for different concerns:
// app/products/[slug]/page.tsx
import { cache } from '@/lib/cache';
async function getProduct(slug: string) {
'use cache: remote';
cache.public.products.bySlug.cacheTag({ slug });
return db.products.findUnique({
where: { slug, status: 'published' },
include: {
category: true,
brand: true,
images: true,
variants: true,
},
});
}
async function getProductInventory(productId: string) {
'use cache: remote';
cache.public.products.inventory.byId.cacheTag({ productId });
return db.inventory.findUnique({
where: { productId },
select: { quantity: true, reserved: true },
});
}
async function getProductReviews(productId: string) {
'use cache: remote';
cache.public.reviews.byProductId.cacheTag({ productId });
return db.reviews.findMany({
where: { productId, approved: true },
orderBy: { createdAt: 'desc' },
take: 10,
include: { user: { select: { name: true } } },
});
}
async function getAverageRating(productId: string) {
'use cache: remote';
cache.public.reviews.average.cacheTag({ productId });
const result = await db.reviews.aggregate({
where: { productId, approved: true },
_avg: { rating: true },
_count: true,
});
return {
average: result._avg.rating ?? 0,
count: result._count,
};
}
export default async function ProductPage({ params }: { params: { slug: string } }) {
const product = await getProduct(params.slug);
if (!product) notFound();
const [inventory, reviews, rating] = await Promise.all([
getProductInventory(product.id),
getProductReviews(product.id),
getAverageRating(product.id),
]);
const inStock = inventory && inventory.quantity - inventory.reserved > 0;
return (
<ProductLayout>
<ProductImages images={product.images} />
<ProductInfo product={product} rating={rating} inStock={inStock} />
<AddToCartButton productId={product.id} disabled={!inStock} />
<ReviewsSection reviews={reviews} rating={rating} />
</ProductLayout>
);
}Pattern 3: Category Pages
// app/categories/[slug]/page.tsx
import { cache } from '@/lib/cache';
async function getCategory(slug: string) {
'use cache: remote';
cache.public.categories.bySlug.cacheTag({ slug });
return db.categories.findUnique({
where: { slug },
include: { children: true },
});
}
async function getProductsByCategory(categoryId: string) {
'use cache: remote';
cache.public.products.byCategory.cacheTag({ categoryId });
return db.products.findMany({
where: {
categoryId,
status: 'published',
},
orderBy: { createdAt: 'desc' },
include: { images: { take: 1 } },
});
}
export default async function CategoryPage({ params }: { params: { slug: string } }) {
const category = await getCategory(params.slug);
if (!category) notFound();
const products = await getProductsByCategory(category.id);
return (
<CategoryLayout category={category}>
<SubcategoryNav subcategories={category.children} />
<ProductGrid products={products} />
</CategoryLayout>
);
}Pattern 4: Inventory Updates
When inventory changes, update caches appropriately:
// app/actions/inventory.ts
'use server';
import { cache } from '@/lib/cache';
export async function updateInventory(productId: string, quantity: number) {
const inventory = await db.inventory.update({
where: { productId },
data: { quantity },
});
// Admin sees immediately
cache.admin.products.inventory.byId.updateTag({ productId });
// Public uses SWR (brief stale data acceptable)
cache.public.products.inventory.byId.revalidateTag({ productId });
// If now out of stock, update product lists
if (quantity === 0) {
cache.public.products.list.revalidateTag();
cache.public.products.featured.revalidateTag();
const product = await db.products.findUnique({
where: { id: productId },
select: { categoryId: true },
});
if (product) {
cache.public.products.byCategory.revalidateTag({
categoryId: product.categoryId,
});
}
}
return inventory;
}
export async function reserveInventory(productId: string, quantity: number) {
// Called when item is added to cart
const inventory = await db.inventory.update({
where: { productId },
data: { reserved: { increment: quantity } },
});
// Inventory display should update
cache.public.products.inventory.byId.revalidateTag({ productId });
return inventory;
}
export async function releaseInventory(productId: string, quantity: number) {
// Called when item is removed from cart or cart expires
const inventory = await db.inventory.update({
where: { productId },
data: { reserved: { decrement: quantity } },
});
cache.public.products.inventory.byId.revalidateTag({ productId });
return inventory;
}Pattern 5: Cart Operations
// lib/cart.ts
import { cache } from '@/lib/cache';
import { getSession } from '@/lib/session';
async function getCart() {
const session = await getSession();
if (session.userId) {
return getCartByUserId(session.userId);
} else {
return getCartBySessionId(session.id);
}
}
async function getCartByUserId(userId: string) {
'use cache: private';
cache.customer.cart.byUserId.cacheTag({ userId });
return db.carts.findUnique({
where: { userId },
include: {
items: {
include: {
product: {
include: { images: { take: 1 } },
},
},
},
},
});
}
async function getCartBySessionId(sessionId: string) {
'use cache: private';
cache.customer.cart.bySessionId.cacheTag({ sessionId });
return db.carts.findUnique({
where: { sessionId },
include: {
items: {
include: {
product: {
include: { images: { take: 1 } },
},
},
},
},
});
}
// app/actions/cart.ts
'use server';
export async function addToCart(productId: string, quantity: number) {
const session = await getSession();
// Add item to cart in database
const cart = await db.carts.upsert({
where: session.userId
? { userId: session.userId }
: { sessionId: session.id },
create: {
userId: session.userId,
sessionId: session.userId ? undefined : session.id,
items: {
create: { productId, quantity },
},
},
update: {
items: {
upsert: {
where: { cartId_productId: { cartId: 'cart', productId } },
create: { productId, quantity },
update: { quantity: { increment: quantity } },
},
},
},
});
// Reserve inventory
await reserveInventory(productId, quantity);
// Update cart cache
if (session.userId) {
cache.customer.cart.byUserId.updateTag({ userId: session.userId });
} else {
cache.customer.cart.bySessionId.updateTag({ sessionId: session.id });
}
return cart;
}
export async function removeFromCart(productId: string) {
const session = await getSession();
// Get current quantity before removing
const cartItem = await db.cartItems.findFirst({
where: {
productId,
cart: session.userId
? { userId: session.userId }
: { sessionId: session.id },
},
});
if (cartItem) {
// Release inventory
await releaseInventory(productId, cartItem.quantity);
// Remove from cart
await db.cartItems.delete({ where: { id: cartItem.id } });
// Update cart cache
if (session.userId) {
cache.customer.cart.byUserId.updateTag({ userId: session.userId });
} else {
cache.customer.cart.bySessionId.updateTag({ sessionId: session.id });
}
}
}Pattern 6: Product Publishing Workflow
// app/actions/products.ts
'use server';
import { cache } from '@/lib/cache';
export async function publishProduct(productId: string) {
const product = await db.products.update({
where: { id: productId },
data: { status: 'published' },
});
// Admin sees immediately
cache.admin.products.byId.updateTag({ id: productId });
cache.admin.products.list.updateTag();
// Public pages update
cache.public.products.byId.revalidateTag({ id: productId });
cache.public.products.bySlug.revalidateTag({ slug: product.slug });
cache.public.products.list.revalidateTag();
cache.public.products.featured.revalidateTag();
cache.public.products.byCategory.revalidateTag({ categoryId: product.categoryId });
if (product.brandId) {
cache.public.products.byBrand.revalidateTag({ brandId: product.brandId });
}
return product;
}
export async function unpublishProduct(productId: string) {
const product = await db.products.update({
where: { id: productId },
data: { status: 'draft' },
});
// Immediately remove from all public caches
cache.public.products.byId.updateTag({ id: productId });
cache.public.products.bySlug.updateTag({ slug: product.slug });
cache.public.products.list.revalidateTag();
cache.public.products.featured.revalidateTag();
cache.public.products.byCategory.revalidateTag({ categoryId: product.categoryId });
cache.admin.products.byId.updateTag({ id: productId });
return product;
}Pattern 7: Category Reorganization
// app/actions/categories.ts
'use server';
export async function moveCategory(categoryId: string, newParentId: string | null) {
await db.categories.update({
where: { id: categoryId },
data: { parentId: newParentId },
});
// Category structure changed - update all category caches
cache.admin.categories.revalidateTag();
cache.public.categories.revalidateTag();
// Products in this category may need updating
cache.public.products.byCategory.revalidateTag({ categoryId });
}
export async function deleteCategory(categoryId: string) {
// Move products to parent category first
const category = await db.categories.findUnique({
where: { id: categoryId },
});
await db.products.updateMany({
where: { categoryId },
data: { categoryId: category?.parentId ?? 'uncategorized' },
});
await db.categories.delete({ where: { id: categoryId } });
// Invalidate all category caches
cache.categories.revalidateTag();
// Product lists need updating
cache.public.products.list.revalidateTag();
cache.public.products.byCategory.revalidateTag({ categoryId });
}Frequently Asked Questions
How do I update a product price and have customers see it immediately?
Use updateTag() for pricing - customers should never see stale prices.
async function updateProductPrice(productId: string, newPrice: number) {
await db.products.update({ where: { id: productId }, data: { price: newPrice } });
// Admin dashboard updates immediately
cache.admin.products.pricing.byId.updateTag({ productId });
// → updateTag('admin/products/pricing/byId:prod-123')
// Public product pages also update immediately (pricing is critical)
cache.public.products.pricing.byId.updateTag({ productId });
// → updateTag('public/products/pricing/byId:prod-123')
cache.public.products.byId.updateTag({ id: productId });
// → updateTag('public/products/byId:prod-123')
}UX: All customers see the correct price immediately. No one ever checks out with a stale price. This may cause a brief loading state, but pricing accuracy is more important than avoiding spinners.
How do I update inventory and only revalidate (allow brief staleness)?
Use revalidateTag() for inventory - a brief delay in "In Stock" status is acceptable.
async function updateInventory(productId: string, quantity: number) {
await db.inventory.update({ where: { productId }, data: { quantity } });
// Inventory display updates in the background
cache.public.products.inventory.byId.revalidateTag({ productId });
// → revalidateTag('public/products/inventory/byId:prod-123', 'max')
}UX: The "In Stock" badge might show stale data for a few seconds, but users never see a loading spinner when browsing products. The inventory refreshes in the background. This is acceptable because the actual checkout process should validate inventory in real-time anyway.
How do I handle flash sales with aggressive cache invalidation?
Use updateTag() at sale boundaries so prices switch exactly on time.
async function startFlashSale(saleId: string, productIds: string[]) {
await db.sales.update({ where: { id: saleId }, data: { active: true } });
// Invalidate all affected products immediately
for (const productId of productIds) {
cache.public.products.pricing.byId.updateTag({ productId });
// → updateTag('public/products/pricing/byId:prod-123')
cache.public.products.byId.updateTag({ id: productId });
// → updateTag('public/products/byId:prod-123')
}
// Invalidate the featured products section
cache.public.products.featured.updateTag();
// → updateTag('public/products/featured')
}UX: Sale prices appear exactly when the sale starts. No customer sees pre-sale prices after launch. The same approach works for ending sales - prices revert to normal immediately.
How do I prevent showing out-of-stock items in product listings?
Invalidate the product list caches when inventory hits zero.
async function updateInventory(productId: string, newQuantity: number) {
const oldInventory = await db.inventory.findUnique({ where: { productId } });
await db.inventory.update({ where: { productId }, data: { quantity: newQuantity } });
// Always update the specific product's inventory
cache.public.products.inventory.byId.revalidateTag({ productId });
// → revalidateTag('public/products/inventory/byId:prod-123', 'max')
// If transitioning to/from out-of-stock, update listings
const wasInStock = oldInventory.quantity > 0;
const isInStock = newQuantity > 0;
if (wasInStock !== isInStock) {
cache.public.products.list.revalidateTag();
// → revalidateTag('public/products/list', 'max')
cache.public.products.featured.revalidateTag();
// → revalidateTag('public/products/featured', 'max')
const product = await db.products.findUnique({ where: { id: productId } });
cache.public.products.byCategory.revalidateTag({ categoryId: product.categoryId });
// → revalidateTag('public/products/byCategory:electronics', 'max')
}
}UX: When an item goes out of stock, it disappears from browsing pages (listings, featured, category pages) after the background revalidation. Customers won't waste time clicking on unavailable products.
How do I update the cart without affecting other users' carts?
Use the customer scope with user/session IDs for complete isolation.
async function addToCart(userId: string, productId: string, quantity: number) {
await db.carts.addItem({ userId, productId, quantity });
// Only invalidate this specific user's cart
cache.customer.cart.byUserId.updateTag({ userId });
// → updateTag('customer/cart/byUserId:user-789')
}
// For anonymous users, use session ID
async function addToCartAnonymous(sessionId: string, productId: string, quantity: number) {
await db.carts.addItem({ sessionId, productId, quantity });
cache.customer.cart.bySessionId.updateTag({ sessionId });
// → updateTag('customer/cart/bySessionId:sess-abc')
}UX: Each user's cart is completely isolated. Adding items to your cart never affects anyone else's cached cart data. The user sees their updated cart immediately.
How do I invalidate all products in a category when reorganizing?
Use branch-level invalidation to clear all products in a category at once.
async function reorganizeCategory(categoryId: string) {
// ... move products around ...
// Invalidate all products in this category (branch-level)
cache.public.products.byCategory.revalidateTag({ categoryId });
// → revalidateTag('public/products/byCategory:cat-123', 'max')
// Also invalidate the category tree structure
cache.public.categories.revalidateTag();
// → revalidateTag('public/categories', 'max')
}
// For nuclear option: invalidate ALL products across all categories
async function rebuildEntireCatalog() {
cache.public.products.revalidateTag();
// → revalidateTag('public/products', 'max')
}UX: Category pages rebuild with the correct products after reorganization. Using branch-level invalidation (products.byCategory with a specific categoryId) is surgical - it only affects that category. The nuclear option (products.revalidateTag()) clears everything but should be used sparingly.
Key Takeaways
- Separate inventory from product data: Cache them independently for different freshness
- Cart is user-specific: Use customer scope with session/user IDs
- Publishing affects many caches: Use a comprehensive invalidation strategy
- Inventory reservation: Track reserved quantities to prevent overselling
- Category changes are structural: May need to invalidate broad areas