← Docs
Recipe

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