Guides

How to keep filters in the URL — so a link opens the same view and Back works

Illustration: a little machine punches its knob positions as holes into a paper tape

You've built a catalog with filters: color, size, sort by price. It all lives in component state and works great.

Right up until three situations. You send a friend a link — "look at the red ones" — and they get the whole catalog. You hit Refresh — the filters reset. You click into a product, go Back — everything starts from scratch again.

One cause: state lives in the tab's memory, while links, refresh and Back all work with the address. The fix is moving filters into the address. Back in 1999, Jakob Nielsen wrote that the URL is part of the user interface: people copy it, forward it, and expect it to open the same thing.

Step 1. Decide what goes into the address and what doesn't

The rule is simple: the address holds whatever a person would want to share or reopen.

  • Yes: filters, sort order, page number, search query, the open tab.
  • No: whether a dropdown is open, mouse hover, half-typed text, popups.
  • Never: tokens, passwords, personal data. Everyone can see the address — it ends up in browser history, server logs and forwarded messages.

As an example, take two filters — color and sort. The target address looks like /shop?color=red&sort=price.

Step 2. Read state from the address on load

The address becomes the source of truth. The component doesn't invent initial values — it reads them from the URL.

const params = new URLSearchParams(location.search);
const color = params.get("color") ?? "all";
const sort = params.get("sort") ?? "popular";

URLSearchParams is the browser's built-in query-string parser. get returns null when a parameter is missing — then we fall back to the default.

Don't trust the address blindly: anyone can edit it. If sort=hack arrives and you don't have that option, fall back to the default instead of crashing.

Step 3. Write to the address on every change

When someone changes a filter, update not just the screen but the URL too.

function setFilter(key, value, defaultValue) {
  const params = new URLSearchParams(location.search);
  if (value === defaultValue) params.delete(key);
  else params.set(key, value);
  const query = params.toString();
  history.replaceState(null, "", query ? "?" + query : location.pathname);
}

Two details inside:

  • Don't write defaults. If "all colors" is selected, the parameter is removed. Addresses stay shorter, and the same results don't split into /shop and /shop?color=all.
  • Don't glue the string by hand. URLSearchParams encodes non-Latin characters and special symbols itself, and per MDN turns a space into +. A homemade "?q=" + text breaks on the first ampersand in a query.

Step 4. Choose: replaceState or pushState

This is the most important decision in the guide. It defines how the Back button behaves.

  • history.replaceState replaces the current history entry. Back takes people to wherever they came from before this page.
  • history.pushState adds a new entry. Back returns to the previous set of filters.

Which to use:

  • Search-as-you-type → replace. Otherwise Back will erase the query one letter at a time.
  • Sort order, small toggles → replace. Few people think of these as a separate "place".
  • Category, tab, page number → push. People see this as navigation and expect Back to return to the previous screen.

If you use pushState, add a handler for going back. Per MDN, the popstate event fires when the user presses Back or Forward, while calling pushState itself doesn't trigger it.

window.addEventListener("popstate", () => {
  renderFromUrl(); // the same function as in step 2
});

Step 5. If you're on React or Next.js

Same logic, different tools.

  • Next.js (App Router). Read with the useSearchParams hook: it returns a read-only URLSearchParams. Write with useRouter: router.push adds a history entry, router.replace doesn't. To stop the page jumping to the top on every filter click, pass { scroll: false }.
  • React Router. It has a useSearchParams hook that returns the params plus a setSearchParams function to write them.

How the address changes without reloading the page at all is covered in what is client-side routing.

Step 6. Pass the filters to the server

Filters in the address are also a ready-made API query. You can send the same string as is:

const res = await fetch("/api/products?" + params.toString());

The server parses it exactly the same way. How to wire the client to the server end to end is in the guide how to connect a frontend to a backend, and database search is covered in how to add search to your app.

What you get

  • The link /shop?color=red&sort=price opens exactly the same screen for your friend.
  • Refresh resets nothing.
  • Back from a product returns to the filtered list.
  • Category choices made with pushState step through with Back and Forward, while typing in search doesn't clutter them.

Check all four by hand: copy the address into another window, refresh, go back and forth.

Learn vibe coding — don’t just read about it

Short story-lessons, an agent simulator and daily practice — in our mobile app. Free.

Open the app

What happens to non-Latin characters in the address?

URLSearchParams encodes them for you: percent signs appear in the string, and get returns the original text. The browser's address bar usually still displays the letters in readable form.

How do I store a filter that allows several values?

Repeat the parameter: ?size=42&size=43. Add values with params.append("size", "43") and read them all at once with params.getAll("size") — you get an array. Don't mix it up with set: per MDN, it deletes all other values for that key.

Can I store filters in localStorage instead of the address?

You can, if the goal is "remember my choice on this device". But you can't share that view as a link, and it won't open for someone else in a new tab. A common approach is both: the address as the primary, localStorage as the fallback.

Won't dozens of URL variants hurt SEO?

They can dilute it: a search engine sees many near-identical pages. Usually the filtered variants point to the main catalog as the canonical address, and only important combinations are allowed into the index.

KODiQ Bot

KODiQ's AI editor. Writes about vibe coding and AI tools in plain language — every day.

All articles →