Dev.to WebDev 🛠 Dev 👁 0 📖 4 min read

Category filters vs. Google: which filter URLs we let get indexed on a hardware store

Filters are the most useful part of a category page for a shopper and the easiest way to hand Google a near-infinite pile of junk URLs. Every checkbox is a new query string. Combine size, brand, material and price range,

Filters are the most useful part of a category page for a shopper and the easiest way to hand Google a near-infinite pile of junk URLs. Every checkbox is a new query string. Combine size, brand, material and price range, add sort order, add pagination, and a single category turns into thousands of crawlable variations that mostly show the same products.

On a hardware store this gets bad quickly, because the filters are genuinely needed. Someone looking at pipe fittings really does want to narrow by diameter and connection type. Someone in screws and anchors wants length, head type and pack size. You can't remove the filters to make SEO happy. So the question becomes: which of these URLs deserve to exist as pages, and which are just UI state?

Here's how we split it.

Three kinds of filter URL

We stopped treating "filtered URL" as one thing. There are roughly three:

  1. Landing-worthy filters. One filter value that matches something people actually search for. "Half inch fittings", "drywall screws", a specific brand inside a category. These can be real pages.
  2. Useful-but-not-searchable combinations. Size + brand + material. Perfectly fine for a shopper, nobody types it into Google.
  3. Pure state. Sort order, items per page, view mode (grid/list), price slider positions. These should never be pages.

Most of the work is deciding what goes into bucket 1. Everything else gets handled mechanically.

Bucket 1: give it a clean path, not a query string

If a filter is worth indexing, it gets a stable path and its own title, H1 and a short intro paragraph, the same as a category. In Next.js App Router that's just another dynamic segment:

// app/[locale]/categories/[slug]/[facet]/page.tsx
export async function generateMetadata({ params }) {
  const { slug, facet } = await params;
  const landing = await getFacetLanding(slug, facet); // from the DB
  if (!landing) return { robots: { index: false } };

  return {
    title: landing.title,
    description: landing.intro,
    alternates: { canonical: landing.canonicalPath },
  };
}

The important part is getFacetLanding. Which facets get a landing page is a row in the database, not a list in the code. Someone who knows the catalog decides "this one is worth a page", writes the intro, and it appears. If a landing page stops making sense (the brand is discontinued, the size never sold), the row is switched off and the route falls back to noindex. No deploy needed.

That rule came from getting it wrong first: we had a hardcoded array of "SEO facets" in a config file, and it drifted out of date the same way any hardcoded business list does.

Bucket 2: crawlable for users, canonical to the parent

Multi-filter combinations stay as query strings (?size=1-2&brand=vero). They render normally, they're shareable, the back button works. But:

  • the canonical points at the unfiltered category (or at the bucket-1 landing page if exactly one landing-worthy filter is applied)
  • they're noindex, follow, so links to products still get discovered
  • they don't go in the sitemap
function filterRobots(searchParams: URLSearchParams, landing?: FacetLanding) {
  const active = [...searchParams.keys()].filter(k => FILTER_KEYS.has(k));
  if (active.length === 0) return { index: true, follow: true };
  if (active.length === 1 && landing) return { index: true, follow: true };
  return { index: false, follow: true };
}

A note on noindex + canonical together: Google says not to rely on combining them as a strong signal, and that's fair. We use the canonical for consolidation and noindex as the backstop for combinations that should never show up in results. In practice the filtered pages mostly just stop appearing in the "indexed" report over time, which is what we wanted.

Bucket 3: don't make it a link at all

Sort order and view mode were the real crawl traps. Each sort option was an <a href="?sort=price_asc">, so every category and every filtered variation had four or five sorted twins, all linked from the page itself.

The fix is boring: sort is a <select> that updates the URL with router.replace and doesn't render crawlable anchors. The parameter still works if someone shares the link, it's just not advertised to crawlers. Same for grid/list.

<select
  value={sort}
  onChange={e => {
    const p = new URLSearchParams(searchParams);
    p.set('sort', e.target.value);
    router.replace(`${pathname}?${p}`, { scroll: false });
  }}
>

Filter checkboxes are trickier, because you do want users to be able to open them in new tabs. We kept them as links but only bucket-1 filters link to their clean path. Combination filters are links with rel="nofollow". Not a guarantee of anything, but it removes the most obvious "follow every combination" path.

Pagination is its own thing

Page 2 of a category is not a filter. It has different products on it and it's how crawlers reach older items. So paginated pages are indexable, self-canonical (page 2 canonicals to page 2, not to page 1), and linked with plain anchors. Pointing every page's canonical at page 1 is a common mistake and it hides everything past the first page.

Checking that it actually works

What we look at, roughly in order:

  • Search Console's page indexing report filtered to URLs with ?. If combination URLs are piling up under "Indexed", something is leaking.
  • Crawl stats, to see whether Googlebot is spending its time on query-string URLs.
  • A quick crawl of one big category with a desktop crawler, counting unique URLs discovered from it. If that number keeps growing as you go deeper, there's a link generating combinations you forgot about.

None of this is clever. The useful shift for us was deciding up front that most filter URLs are UI state, and making "this one deserves to be a page" an explicit decision someone makes, stored where it can be changed without touching code.

If you've handled facets differently, especially on a store with lots of size variants, I'd like to hear how you decided what gets indexed.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.