Faceted search + filter UX
Build a multi-axis product browser with real-time counts, URL-synced state, and zero layout shift.
Ingredients
- Next.js App Router + searchParams
- Server Components for initial render
- useTransition for pending UI
- Facet counts computed server-side
Core pattern
// page.tsx
export default function Search({
searchParams,
}: {
searchParams: { [key: string]: string | undefined }
}) {
const filters = parseSearchParams(searchParams);
const { results, facets } = await search(filters);
return (
<div className="flex gap-8">
<FacetPanel facets={facets} active={filters} />
<ProductGrid items={results} />
</div>
);
}Facet panel
// FacetPanel.tsx
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useTransition } from "react";
export function FacetPanel({ facets, active }) {
const router = useRouter();
const searchParams = useSearchParams();
const [pending, startTransition] = useTransition();
function toggle(key: string, value: string) {
const next = new URLSearchParams(searchParams);
const current = next.getAll(key);
if (current.includes(value)) {
next.delete(key);
current.filter(v => v !== value).forEach(v => next.append(key, v));
} else {
next.append(key, value);
}
startTransition(() => router.push(`/?${next}`));
}
return (
<aside className="w-64 space-y-6">
{facets.map(f => (
<fieldset key={f.key}>
<legend className="font-medium">{f.label}</legend>
{f.values.map(v => (
<label
key={v.value}
className="flex items-center gap-2 text-sm"
>
<input
type="checkbox"
checked={active[f.key]?.includes(v.value)}
onChange={() => toggle(f.key, v.value)}
/>
{v.label}
<span className="ml-auto text-gray-500">({v.count})</span>
</label>
))}
</fieldset>
))}
{pending && (
<div className="text-sm text-[#8B5CF6]">Updating...</div>
)}
</aside>
);
}Key decisions
- • Facet counts reflect the intersection of all active filters — never stale
- • URL is the source of truth; shareable, bookmarkable, back-button safe
- • useTransition keeps the current UI visible while the server computes
- • Zero client-side data fetching — everything streams from RSC