Skip to content

10.2 — Category / PLP playbook

Module 10 · Lesson 2 · 🟡 Intermediate · ~30 min

The page where discovery happens, where INP goes to die, and where 27% of your sessions live.


Profile

Share of sessions ~27%
Cacheability Page 1 unfiltered: high. Filtered combinations: low
LCP element Hero banner, or the first product tile image
Dominant risks Filter INP, image grid weight, facet query cost, prefetch storms
Rendering strategy ISR for page 1, dynamic for filtered views with cached data

Budgets

Metric Target
LCP ≤ 2.5 s
INP ≤ 180 ms ← the tight one
CLS ≤ 0.05
TTFB ≤ 400 ms
JS (gz) ≤ 240 KB
Above‑fold images ≤ 400 KB

The structure

// app/c/[slug]/page.tsx
export const revalidate = 300;

export async function generateStaticParams() {
  const top = await getTopCategories({ limit: 500 });
  return top.map((c) => ({ slug: c.slug }));
}

export default async function CategoryPage({
  params, searchParams,
}: {
  params: Promise<{ slug: string }>;
  searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
  const { slug } = await params;
  const filters = normalizeFilters(new URLSearchParams(await searchParams as never));

  // Category metadata is cached and shared across all filter combinations
  const category = await getCategory(slug);
  if (!category) notFound();

  return (
    <main>
      <Breadcrumbs category={category} />
      <CategoryHero category={category} />   {/* server, priority image if it's the LCP */}

      <div className="grid lg:grid-cols-[280px_1fr] lg:gap-8">
        {/* Facets stream — they're the slowest query */}
        <Suspense fallback={<FacetsSkeleton />}>
          <FacetSidebar categoryId={category.id} filters={filters} />
        </Suspense>

        {/* Results stream. key on filters ONLY for a genuinely new query,
            not for refinements wrapped in a transition — see 5.5 */}
        <Suspense fallback={<ProductGridSkeleton count={12} />}>
          <ProductGrid categoryId={category.id} filters={filters} />
        </Suspense>
      </div>
    </main>
  );
}

Problem 1 — Filter INP

The defining PLP problem. Full treatment in 5.3 and 5.5; here's the assembled solution.

// components/facet-checkbox.tsx
'use client';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { useTransition, useOptimistic } from 'react';

export function FacetCheckbox({ facet, value, label, count }: Props) {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const [isPending, startTransition] = useTransition();

  const serverChecked = searchParams.getAll(facet).includes(value);
  // Optimistic so the checkbox flips in the same frame as the tap
  const [checked, setChecked] = useOptimistic(serverChecked);

  const toggle = () => {
    const params = new URLSearchParams(searchParams);
    const current = params.getAll(facet);
    params.delete(facet);
    (checked ? current.filter((v) => v !== value) : [...current, value])
      .forEach((v) => params.append(facet, v));
    params.delete('page');                     // filter change resets pagination

    startTransition(() => {
      setChecked(!checked);                    // URGENT-ish: inside the transition,
                                               // but optimistic so it paints immediately
      router.push(`${pathname}?${params}`, { scroll: false });
    });
  };

  return (
    <label className="flex cursor-pointer items-center gap-2 py-1.5">
      <input type="checkbox" checked={checked} onChange={toggle} />
      <span>{label}</span>
      <span className="text-neutral-500 tabular-nums">({count})</span>
    </label>
  );
}
// The grid dims while pending instead of flashing to a skeleton
'use client';
export function GridPendingWrapper({ children }: { children: React.ReactNode }) {
  const [isPending] = useTransition();
  return (
    <div className={isPending ? 'opacity-60 transition-opacity duration-150' : ''}>
      {children}
    </div>
  );
}

Why URL state and not client state: filtering happens on the server against cached data, so the 48‑card client re‑render disappears entirely. The trade is a round trip, which startTransition and the optimistic checkbox make invisible.

Approach Checkbox response Results update Client JS
Client state, no transition 412 ms 412 ms Filter engine + all products
Client state + transition 14 ms 380 ms Filter engine + all products
URL state + transition 9 ms 160 ms None

Problem 2 — The image grid

48 tiles × the wrong sizes is the most common bytes problem in commerce.

// components/product-tile.tsx — Server Component
import Image from 'next/image';

export function ProductTile({ product, index }: { product: Product; index: number }) {
  return (
    <article className="group">
      <a href={`/p/${product.slug}`} className="block">
        <div className="relative aspect-[3/4] overflow-hidden bg-neutral-100">
          <Image
            src={product.image}
            alt={product.name}
            fill
            // The real rendered widths — measure them, don't guess (see 2.1)
            sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, (max-width: 1536px) 25vw, 320px"
            priority={index < 4}          // above the fold only
            decoding="async"
            className="object-cover transition-transform duration-300 group-hover:scale-105"
          />
        </div>
        <h3 className="mt-2 text-sm">{product.name}</h3>
        {/* Formatted on the server — no Intl construction in a client component */}
        <p className="text-sm tabular-nums">{product.formattedPrice}</p>
      </a>
      {/* The only client code on the tile */}
      <WishlistButton productId={product.id} initial={product.isWishlisted} />
    </article>
  );
}

Plus content-visibility for off‑screen tiles:

.product-tile { content-visibility: auto; contain-intrinsic-size: auto 420px; }
/* Never on the first row — it can delay LCP */
.product-tile:nth-child(-n+4) { content-visibility: visible; }

Problem 3 — Prefetch storms

48 tiles, all <Link>, all prefetching on viewport entry = 48 RSC requests competing with your images.

// First 8 auto-prefetch; the rest on hover/touch intent (see 8.1)
<ProductTileLink slug={product.slug} eager={index < 8}>
  <ProductTile product={product} index={index} />
</ProductTileLink>

Check it: Network panel, filter _rsc, load the PLP. Over ~10 requests means you're stealing bandwidth from your LCP.


Problem 4 — Facet query cost

Facet counts are typically the slowest part of a PLP request (7.3).

// Facets in their own Suspense boundary so they never block the grid
<Suspense fallback={<FacetsSkeleton />}>
  <FacetSidebar categoryId={category.id} filters={filters} />
</Suspense>

// Inside: only the facets shown above the fold
async function FacetSidebar({ categoryId, filters }: Props) {
  const facets = await getFacetCounts(categoryId, filters, {
    only: ['category', 'brand', 'color', 'size', 'price', 'rating'],   // not all 24
    limitPerFacet: 12,
  });
  return (
    <aside>
      {facets.map((f) => <FacetGroup key={f.key} facet={f} />)}
      <MoreFiltersDrawer categoryId={categoryId} filters={filters} />   {/* loads on click */}
    </aside>
  );
}

Problem 5 — Pagination and scroll restoration

// Server-rendered pagination: cacheable, crawlable, bounded memory
<nav aria-label="Pagination">
  {page > 1 && <a href={buildHref(page - 1)} rel="prev">Previous</a>}
  {pageNumbers.map((p) => (
    <a key={p} href={buildHref(p)} aria-current={p === page ? 'page' : undefined}>{p}</a>
  ))}
  {page < totalPages && <a href={buildHref(page + 1)} rel="next">Next</a>}
</nav>

Scroll restoration on back navigation is the most‑complained‑about PLP bug and it's worth more to users than most of this page.

// The best fix is bfcache — it restores scroll for free. See 8.3.
// For client-side navigations, save and restore explicitly:
'use client';
export function ScrollRestoration({ key }: { key: string }) {
  useEffect(() => {
    const saved = sessionStorage.getItem(`scroll:${key}`);
    if (saved) window.scrollTo(0, Number(saved));

    const save = () => sessionStorage.setItem(`scroll:${key}`, String(window.scrollY));
    window.addEventListener('pagehide', save);
    return () => { save(); window.removeEventListener('pagehide', save); };
  }, [key]);
  return null;
}

Diagnosis order

PLP INP > 180ms?
├─ Are filters in client state?              → move to URL, [5.3]
├─ Is the update wrapped in startTransition? → [5.5]
├─ Are 48 cards re-rendering?                → memo + stable props, [5.2]
├─ Is the cart context re-rendering tiles?   → split the context, [5.3]
├─ Are formatters constructed per tile?      → format on the server, [4.3]
└─ Are third parties eating input delay?     → [2.3], [6.2] Section A

PLP LCP > 2.5s?
├─ Is `sizes` right on the tiles?            → [2.1] Bug 2
├─ Is the first row priority?                → index < 4
├─ Are 48 routes prefetching?                → [8.1]
├─ Is the facet query blocking the shell?    → Suspense boundary
└─ Is TTFB > 400ms?                          → [6.4]

PLP CLS > 0.05?
├─ Tiles without aspect-ratio?               → aspect-[3/4] container
├─ Facet sidebar appearing late?             → sized skeleton
├─ Badges/ratings loading late?              → reserve height
└─ Infinite scroll inserting content?        → append only, reserve loading row

Checklist

  • ISR for page 1 of each category; dynamic filtered views over cached data
  • Filters, sort, and pagination in the URL
  • Filter interactions wrapped in startTransition with optimistic checkbox state
  • Grid dims on pending; never flashes to a skeleton on refinement
  • Product tiles are Server Components with client leaves only
  • sizes verified against real rendered widths at each breakpoint
  • Only the first row is priority
  • content-visibility on off‑screen tiles with contain-intrinsic-size
  • Prefetch limited to the first ~8 tiles, then hover intent
  • Facets in their own Suspense boundary, allowlisted, capped
  • Server‑rendered pagination; depth capped
  • Scroll restoration works on back navigation

Next: 10.3 Product / PDP