TidyURLs
Go to Tools
BLOG
The TidyURLsJournal

Practical guides, tutorials, and insights about URLs, SEO, web development, and online marketing. Learn, troubleshoot, and get the most of the web.

Back to the Journal
TidyURLs Journal

How to Sync Filters with URL Query Parameters

Practical insights, clear explanations, and useful ideas for understanding the web.

October 28, 20267 min read5 topics
How to Sync Filters with URL Query Parameters
Explore the details
#frontend development#URL query parameters#JavaScript#browser history#web development
October 28, 20267 min read

Search and filter state stored only in a page’s memory disappears on reload and can’t be shared as a link. Putting that state in the URL makes filtered views bookmarkable and gives browser back and forward buttons a useful role.

This framework-independent guide shows how to map filter state to query parameters, restore it on page load, handle repeated or invalid values, and choose between pushState and replaceState.

Key Takeaways

  • Use URLSearchParams to encode and read query parameters rather than assembling query strings by hand.
  • Parse URLs into validated application state; don’t trust incoming parameter values.
  • Omit default values when writing URLs, and define a consistent policy for repeated parameters.
  • Use pushState for a meaningful, navigable change and replaceState for edits that shouldn’t create another history entry.
  • Handle popstate by reading the URL again and updating the interface. Calling pushState or replaceState does not itself fire popstate.

Choose a URL format for your filters

Pick parameter names and decide which values are defaults. For example, a product catalog might use:

/products?q=linen&category=home&sort=price-asc&tag=organic&tag=discount

Repeated parameters suit multi-select filters: each selected tag gets its own tag parameter. A comma-separated value is another option, but repeated parameters avoid needing a custom delimiter and make each value easy to read with getAll().

Decide how to handle duplicates for single-value parameters such as sort. In the example below, the first value wins because URLSearchParams.get() returns the first matching value. When the URL is next written, duplicates are removed. Choose a policy that fits your application and apply it consistently.

Parse and validate URL state

Use the URL and URLSearchParams APIs to read the current address. Validate values against the options your interface actually supports, and set limits for free-text input and multi-select values.

const DEFAULT_FILTERS = {
  q: "",
  category: "",
  sort: "relevance",
  tags: []
};

const CATEGORIES = new Set(["home", "clothing", "books"]);
const SORT_OPTIONS = new Set(["relevance", "price-asc", "price-desc"]);
const TAG_OPTIONS = new Set(["organic", "discount", "new"]);

function normalizeFilters(filters) {
  const q = typeof filters.q === "string" ? filters.q.trim().slice(0, 100) : "";
  const category = CATEGORIES.has(filters.category) ? filters.category : "";
  const sort = SORT_OPTIONS.has(filters.sort) ? filters.sort : DEFAULT_FILTERS.sort;
  const tags = Array.isArray(filters.tags)
    ? [...new Set(filters.tags
        .filter((tag) => typeof tag === "string")
        .map((tag) => tag.trim())
        .filter((tag) => TAG_OPTIONS.has(tag)))].slice(0, 10)
    : [];

  return { q, category, sort, tags };
}

function readFilters(url = new URL(window.location.href)) {
  const params = url.searchParams;
  return normalizeFilters({
    q: params.get("q") ?? "",
    category: params.get("category") ?? "",
    sort: params.get("sort") ?? DEFAULT_FILTERS.sort,
    tags: params.getAll("tag")
  });
}

The allowlists here are examples; replace them with the real choices in your application. Unknown categories, sort orders, and tags fall back to defaults or are discarded. The query is trimmed and limited in length. These checks keep the interface state predictable; they are not a substitute for server-side validation when the server uses the same values.

URLSearchParams handles query-string encoding and decoding. Malformed percent-encoded sequences are generally parsed permissively rather than making get() throw, so validate the resulting values instead of relying on parsing to reject bad input. If a value is absent, use the default. If it appears more than once, apply your documented duplicate policy.

Write filters without breaking other URL state

When updating filters, change only the parameters your feature owns. That preserves unrelated query parameters and the URL fragment. Delete the owned parameters first so removed filters and duplicate single-value parameters don’t linger.

function writeFilters(url, filters) {
  const params = url.searchParams;

  for (const key of ["q", "category", "sort", "tag"]) {
    params.delete(key);
  }

  if (filters.q) params.set("q", filters.q);
  if (filters.category) params.set("category", filters.category);
  if (filters.sort !== DEFAULT_FILTERS.sort) {
    params.set("sort", filters.sort);
  }
  for (const tag of filters.tags) {
    params.append("tag", tag);
  }

  return url;
}

let currentFilters = readFilters();

function renderFilters(filters) {
  // Update your framework's state or the page's controls and results here.
  console.log("Render filters:", filters);
}

function saveFilters(nextFilters, { replace = false } = {}) {
  const filters = normalizeFilters(nextFilters);
  const nextUrl = writeFilters(new URL(window.location.href), filters);

  currentFilters = filters;
  if (nextUrl.href !== window.location.href) {
    const method = replace ? "replaceState" : "pushState";
    history[method](history.state, "", nextUrl);
  }
  renderFilters(currentFilters);
}

// Normalize the initial URL without adding a history entry.
const initialUrl = writeFilters(new URL(window.location.href), currentFilters);
if (initialUrl.href !== window.location.href) {
  history.replaceState(history.state, "", initialUrl);
}
renderFilters(currentFilters);

The sample uses history.state to retain the current entry’s state. If your application also stores data there, coordinate ownership of that object and keep it serializable; the History API can reject state it cannot clone. Passing a new object can replace state owned by other code.

A browser can display spaces and other characters in different equivalent encoded forms. Let URLSearchParams do the encoding; don’t concatenate untrusted values into a URL string. If you want a query editor while developing, use the supplied Query Editor tool to inspect query parameters.

Pick pushState or replaceState

Both methods update the address and history state without a full page navigation, but they have different effects on the history stack:

UseWhat it doesTypical filter example
pushStateAdds a history entryA user applies a filter set or selects a new view they should be able to return from
replaceStateUpdates the current entryA live search updates the URL on each keystroke, or the page cleans up invalid and default parameters

If every keystroke uses pushState, pressing Back may walk through each character typed. For live search, update with replaceState while typing, then use pushState for a deliberate action such as submitting the search or applying filters. Another option is to debounce changes and push only after the user pauses; choose a policy that matches how users should navigate.

pushState and replaceState do not trigger a page load or dispatch popstate. Update the interface yourself when saving state, as saveFilters() does. The browser dispatches popstate when the active history entry changes through Back or Forward, so read the URL and render again:

window.addEventListener("popstate", () => {
  currentFilters = readFilters();
  renderFilters(currentFilters);
});

Use the URL as the source of truth during this event rather than assuming event.state contains your filter state. A user may open a shared URL directly, reload the page, or arrive at an entry without the state shape your code expects.

Connect the URL to your interface

Call saveFilters() from the event that represents the user’s action. For example, a live search can replace the current history entry, while applying a set of filters can add one:

searchInput.addEventListener("input", (event) => {
  saveFilters({ ...currentFilters, q: event.currentTarget.value }, { replace: true });
});

applyButton.addEventListener("click", () => {
  saveFilters({
    ...currentFilters,
    category: categorySelect.value,
    tags: [...tagCheckboxes]
      .filter((checkbox) => checkbox.checked)
      .map((checkbox) => checkbox.value)
  });
});

This is plain browser JavaScript, not a complete application: searchInput, applyButton, categorySelect, and tagCheckboxes must refer to elements in your page. In a frontend framework, use the same flow with the framework’s state and rendering mechanisms. Initialize that state from readFilters() and update it from the popstate handler; avoid rendering the same change through competing state systems.

Common Mistakes

  • Building query strings by concatenation. Values may contain spaces, ampersands, or other characters that need encoding. Use URLSearchParams.
  • Treating URL input as valid application state. Check allowed values, lengths, and multi-value limits before using parameters.
  • Dropping unrelated parameters. Start from the current URL and modify only parameters your feature owns.
  • Leaving defaults and stale duplicates in the URL. Delete owned parameters before writing the current state, then omit default values if that is your chosen convention.
  • Using pushState for every keystroke. This can make Back impractical. Use replaceState for transient edits or group changes into meaningful commits.
  • Expecting pushState to update the interface or fire popstate. Render the new state directly; listen for popstate to handle Back and Forward.
  • Assuming URL parsing is security validation. A well-formed query string can still contain unsupported or unsafe values. Validate at every boundary where the data is used.

Best Practices

  1. Define defaults, allowed values, duplicate handling, and parameter names before wiring up the UI.
  2. Parse the URL on initial load and when popstate fires; normalize values before rendering.
  3. Keep URL serialization in one function so initialization and user updates follow the same rules.
  4. Preserve parameters and fragments owned by other parts of the page.
  5. Choose history behavior based on user expectations: replace transient edits, push meaningful navigation steps.
  6. Test direct loads, refreshes, Back and Forward, repeated values, missing values, unknown options, and text containing characters such as & or ?.

Compatibility note: I could not retrieve live browser documentation in this environment. The examples use the long-established History API and URL APIs, but check current browser documentation and your supported-browser matrix before shipping, especially if your application has unusual history-state requirements.

Conclusion

A shareable filter URL needs a consistent mapping, careful input validation, and a deliberate history policy. Parse on load and on popstate, serialize with URLSearchParams, and use pushState or replaceState according to whether a change should create a Back-button step.