next-cool-cache
Scenarios

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

  1. Separate inventory from product data: Cache them independently for different freshness
  2. Cart is user-specific: Use customer scope with session/user IDs
  3. Publishing affects many caches: Use a comprehensive invalidation strategy
  4. Inventory reservation: Track reserved quantities to prevent overselling
  5. Category changes are structural: May need to invalidate broad areas

On this page