Skip to main content
ASoc
Tutorial

Product Filtering in Next.js: Why Filters Belong in the URL

Filters in useState cannot be shared, bookmarked, or server-rendered. How to read them from searchParams — and which filtered URLs to let Google crawl.

The ASoc Team10 min read

Product filters in Next.js belong in the URL, not in useState. Filters held in component state cannot be shared, bookmarked, or restored by the back button, and the server cannot render the filtered result. Reading them from searchParams fixes all four at once — and then you have to decide which filtered URLs search engines are allowed to crawl.

This post covers the server-side read, the client control that writes back to the URL, when in-memory filtering is the better call, and the crawl trap that faceted navigation creates if you let it.

What useState filters actually cost

A filter panel built on useState looks fine in development. Four things break in production:

  • Nothing is shareable. "Here are the black keyboards under $150" is a URL your shopper cannot send anyone.
  • The back button leaves the category. Shoppers expect back to undo the last filter. With local state it exits the page entirely.
  • The server renders the unfiltered page. Every visitor gets all products, then JavaScript hides most of them.
  • Analytics sees one page. You cannot tell which filters people actually use, because every combination reports the same URL.

The URL fixes all four, and it is the browser's own state container — free persistence, free history, free sharing.

Read the filters on the server

In Next.js 16 searchParams is a promise. Await it:

// src/app/shop/page.tsx
type SearchParams = Promise<{
  category?: string;
  brand?: string | string[];
  max?: string;
}>;

export default async function ShopPage({
  searchParams,
}: {
  searchParams: SearchParams;
}) {
  const params = await searchParams;
  const filters = parseFilters(params);
  const products = filterProducts(catalog, filters);

  return <ProductGrid products={products} />;
}

Note brand?: string | string[]. A repeated query param (?brand=a&brand=b) arrives as an array and a single one as a string. Handling only the string case is the most common bug in this code, and it fails exactly when a shopper picks a second checkbox.

Parse and normalize in one place:

export function parseFilters(params: Record<string, string | string[] | undefined>) {
  const list = (v: string | string[] | undefined) =>
    v === undefined ? [] : Array.isArray(v) ? v : [v];

  return {
    categories: list(params.category),
    brands: list(params.brand),
    // Never trust the number — `?max=abc` must not produce NaN comparisons,
    // which silently return false and empty the grid.
    max: Number.isFinite(Number(params.max)) ? Number(params.max) : undefined,
  };
}

Keep the filter itself a pure function

The predicate should not know about React or the URL. That makes it unit-testable, which matters because filter logic is where off-by-one and empty-array bugs live:

export function filterProducts(products: Product[], f: Filters): Product[] {
  return products.filter((p) => {
    // An empty facet means "no constraint", NOT "match nothing". Getting this
    // backwards renders an empty page on first load, which looks like a
    // data-fetching bug and is not one.
    if (f.categories.length && !f.categories.includes(p.category)) return false;
    if (f.brands.length && !f.brands.includes(p.brand)) return false;
    if (f.max !== undefined && p.price > f.max) return false;
    return true;
  });
}

We keep exactly this shape in our own catalog explorer, with the predicate in its own module and a test file beside it. Every facet added since has been a two-line change plus a test.

Write filters back to the URL from a client control

The control that changes a filter must be a Client Component. It updates the URL; the server re-renders the results:

"use client";
import { useRouter, useSearchParams, usePathname } from "next/navigation";

export function FacetCheckbox({ name, value, label }: FacetProps) {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const checked = searchParams.getAll(name).includes(value);

  function toggle() {
    const next = new URLSearchParams(searchParams);
    const current = next.getAll(name);
    next.delete(name);
    for (const v of current.filter((v) => v !== value)) next.append(name, v);
    if (!checked) next.append(name, value);
    // Any filter change invalidates the page cursor.
    next.delete("page");

    router.push(`${pathname}?${next}`, { scroll: false });
  }

  return (
    <label>
      <input type="checkbox" checked={checked} onChange={toggle} /> {label}
    </label>
  );
}

Three details do the work. new URLSearchParams(searchParams) copies rather than mutating the read-only params. next.delete("page") resets pagination — leaving a shopper on page 4 of a result set that now has two pages is a blank screen. And { scroll: false } stops the viewport jumping to the top on every checkbox click, which on a long filter panel feels broken.

Wrap the results in <Suspense> keyed by the filter state so the grid streams while the panel stays interactive:

<Suspense key={JSON.stringify(filters)} fallback={<GridSkeleton />}>
  <ProductGrid filters={filters} />
</Suspense>

When to filter in memory instead

There is an honest counter-case. If the entire catalog is small enough to ship — a few hundred products of metadata, not images — filtering in the browser is faster and simpler than a round trip per checkbox.

Our own /templates page does this: the catalog is over a hundred products, it is already in the bundle for the grid, and filtering is a synchronous array pass. No request, no spinner.

The rule of thumb:

Catalog sizeWhere to filterWhy
Under ~500 itemsIn memory, on the clientThe data is already there; a round trip is pure latency
500 – 10,000Server-side from searchParamsToo much to ship, small enough to scan per request
Over 10,000A search index (Algolia, Typesense, Postgres FTS)Facet counts need an inverted index, not a table scan

Even when filtering in memory, put the filter state in the URL. The two decisions are independent, and shareability is worth having either way.

The crawl trap nobody plans for

This is the part that gets skipped, and it is the one with lasting consequences.

Three facets with five options each produce 216 URL combinations. Add sort order and pagination and you are past ten thousand crawlable URLs for a few hundred products. Google will crawl them, find near-identical content on each, and spend your crawl budget there instead of on the pages you care about. On a young domain the visible symptom is product pages sitting in Search Console as Discovered — currently not indexed: the URLs are known, but nothing is worth crawling.

Decide deliberately which filtered URLs are indexable:

export async function generateMetadata({ searchParams }) {
  const params = await searchParams;
  const facetCount = ["category", "brand", "max"].filter((k) => params[k]).length;

  return {
    // One facet is a real landing page ("black keyboards"). Two or more is a
    // combination nobody searches for, and it should not compete with the
    // pages that do.
    robots: facetCount > 1 ? { index: false, follow: true } : undefined,
    alternates: { canonical: canonicalFor(params) },
  };
}

Then apply three rules:

  1. Index single-facet URLs only — those match real queries.
  2. Canonicalize sort order away. ?sort=price is the same products in a different order; point it at the unsorted URL.
  3. Link, do not just generate. A filtered URL worth indexing deserves a real link from a category page. One nobody links to is one Google has no reason to crawl.

follow: true matters on the noindex variants: it keeps link equity flowing through to the product pages, which is the whole point of the category tier.

Mistakes that cost us time

MistakeSymptomFix
searchParams used without awaitType error, or undefined filtersIt is a promise in Next 16 — await it
Treating a repeated param as a stringSecond checkbox silently ignoredNormalize with Array.isArray
Empty facet treated as "match nothing"Empty grid on first loadEmpty array means no constraint
Number(params.max) unguardedNaN > price is false; grid emptiesGuard with Number.isFinite
Page cursor kept across a filter changeBlank page 4 of a 2-page resultdelete("page") on every change
router.push without scroll: falseViewport jumps on every clickPass { scroll: false }
Every facet combination indexableThousands of thin URLs; crawl budget goneIndex one facet deep; noindex/follow the rest

Frequently asked questions

useSearchParams or the searchParams prop? Both, for different jobs. The searchParams prop is server-side and is what you filter with. The useSearchParams hook is client-side and is what the filter control reads to know its own checked state. Do not fetch data with the hook.

Does reading searchParams make my page dynamic? Yes — a page reading searchParams is rendered per request. That is correct for a filtered listing. Keep the unfiltered category page as its own static route so the version that ranks stays prerendered.

How do I show result counts per facet? Compute them from the same filtered set, applying every facet except the one you are counting — otherwise every unselected option reads zero. At scale this is what search engines with real faceting are for.

Should filters be push or replace? push for filter changes, so back undoes one filter. replace for things that are not navigation, like a debounced search-as-you-type box, which would otherwise leave a history entry per keystroke.

Do filtered pages need their own metadata? Any filtered page you allow to be indexed does — a distinct title and description reflecting the facet. If you are not prepared to write them, that is a sign the URL should be noindex, follow instead.

Starting from a finished storefront

Faceted navigation is one of those features that looks like an afternoon and turns into a fortnight once URL state, pagination, result counts and crawl rules are all in play.

Our Next.js shop templates ship it working. ASoc Market is a full supermarket storefront with a mega-menu across electronics, home and lifestyle; ASoc Circuit is a multi-department electronics marketplace; ASoc Prism is a colour-forward fashion store with cart and wishlist.

Browse all Next.js shop templates, or the Tailwind shop templates if you care more about the design system than the framework.

Keep reading

Tutorial11 min read

A Product Image Gallery in Next.js That Google Can Actually See

Virtualizing a four-slide carousel removes three product images from the HTML. Mount every slide, starve the off-screen ones, and the ARIA that a carousel actually needs.

Read more
Tutorial9 min read

Product Schema in Next.js: JSON-LD That Earns Rich Results

Product needs only one of offers, review or aggregateRating — so a truthful offer qualifies you without inventing reviews. The shape, the validation, the manual-action trap.

Read more
Tutorial12 min read

Product Variants in Next.js Without a Commerce Backend

Size and colour variants render fine from a typed file — until stock has to be authoritative. The option matrix, the URL-driven picker, the canonical, and where it breaks.

Read more