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
startTransitionwith optimistic checkbox state - Grid dims on pending; never flashes to a skeleton on refinement
- Product tiles are Server Components with client leaves only
-
sizesverified against real rendered widths at each breakpoint - Only the first row is
priority -
content-visibilityon off‑screen tiles withcontain-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