Search interfaces accumulate state quickly. A typical product catalog search might combine a free-text query, a dozen active facet values (brand, color, size), a price range, a sort order, a page number, and a results-per-page preference. When that state lives only in React component state, it evaporates the moment the user refreshes the page, copies the URL to share with a colleague, or navigates away and returns with the browser back button. The URL is the natural persistence layer for exactly this kind of ephemeral-but-shareable application state.
URL state synchronization solves this by treating the browser’s query string as the single source of truth for search state. The URL drives React state on mount and after navigation events; React state writes back to the URL on every user interaction. The result is a search UI where the URL always reflects exactly what the user sees—enabling deep linking, bookmarking, sharing, and reliable browser history navigation.
The qs library is the standard tool for this pattern. Native URLSearchParams only handles flat string values, while real search state involves arrays (multiple selected facet values), nested objects (a price range with min and max), and booleans. qs serializes and parses these structures bidirectionally with configurable encoding formats, making it the right tool for complex filter state.
Rather than React state being primary and the URL being a side effect, URL-first design inverts the relationship. On mount, the hook parses the URL to initialize state. On user interaction, state updates write to the URL immediately (or after debounce). This makes the URL the authoritative record of the search session, so window.location.href at any point fully reconstructs the current view.
qs.stringify converts a JavaScript object into a query string; qs.parse converts a query string back into an object. Both accept an options object controlling array encoding (brackets, indices, repeat, comma), nested object encoding, and type coercion. Using the same options for both directions guarantees a round-trip: qs.parse(qs.stringify(state, opts), opts) always returns a structurally equivalent object.
Writing to the URL on every keystroke in a text input pushes a new entry into browser history for each character typed, making the back button nearly unusable. The correct approach is to debounce URL writes (typically 300–500ms for text inputs) while keeping React state updates immediate. The user sees instant feedback in the UI; the URL stabilizes only after the user stops typing. Facet checkbox changes, which are discrete interactions, can be written to the URL immediately without debouncing.
qs supports multiple array serialization formats. The brackets format produces colors[]=red&colors[]=blue, which is readable and widely understood by backend query parsers. The repeat format produces colors=red&colors=blue, which is compatible with URLSearchParams. For nested objects like a price range, qs produces price[min]=10&price[max]=500. Choose a format and use it consistently on both the frontend and backend.
Both React Router and Next.js provide router APIs for reading and writing the query string without a full page reload. React Router exposes useSearchParams (returning a URLSearchParams instance) and setSearchParams. Next.js App Router exposes useSearchParams (read-only) and useRouter with router.push / router.replace. Wrapping these in a custom hook that delegates serialization to qs decouples the rest of the application from router-specific APIs.
Start by modeling the complete search state as a TypeScript interface. This drives both the serialization logic and the component props, and makes the shape of the URL schema explicit.
// types/search.ts
export interface PriceRange {
min: number;
max: number;
}
export interface SearchFacets {
brand: string[];
color: string[];
size: string[];
inStock: boolean;
price: PriceRange;
}
export interface SearchState {
query: string;
facets: SearchFacets;
sort: "relevance" | "price_asc" | "price_desc" | "newest";
page: number;
perPage: number;
}
export const DEFAULT_SEARCH_STATE: SearchState = {
query: "",
facets: {
brand: [],
color: [],
size: [],
inStock: false,
price: { min: 0, max: 10000 },
},
sort: "relevance",
page: 1,
perPage: 24,
};
These two functions form the bidirectional bridge between JavaScript objects and URL query strings. The same qs options must be used in both directions. Wrapping them in a module makes it trivial to swap encoding strategies project-wide.
// lib/searchParams.ts
import qs from "qs";
import { SearchState, DEFAULT_SEARCH_STATE } from "@/types/search";
const QS_OPTIONS: qs.IStringifyOptions & qs.IParseOptions = {
arrayFormat: "brackets", // colors[]=red&colors[]=blue
allowDots: false, // use brackets for nested: price[min]=10
encode: true,
strictNullHandling: true, // null vs undefined distinction
};
/**
* Serialize a SearchState object to a URL query string.
* Omits default values to keep URLs clean.
*/
export function serializeSearchState(state: SearchState): string {
const patch: Partial<Record<string, unknown>> = {};
if (state.query) patch.query = state.query;
// Only include non-empty facet arrays
const facetPatch: Partial<typeof state.facets> = {};
if (state.facets.brand.length) facetPatch.brand = state.facets.brand;
if (state.facets.color.length) facetPatch.color = state.facets.color;
if (state.facets.size.length) facetPatch.size = state.facets.size;
if (state.facets.inStock) facetPatch.inStock = true;
const { min, max } = state.facets.price;
if (min !== 0 || max !== 10000) {
facetPatch.price = { min, max };
}
if (Object.keys(facetPatch).length) patch.facets = facetPatch;
if (state.sort !== "relevance") patch.sort = state.sort;
if (state.page !== 1) patch.page = state.page;
if (state.perPage !== 24) patch.perPage = state.perPage;
return qs.stringify(patch, QS_OPTIONS);
}
/**
* Parse a raw query string into a SearchState, merging with defaults
* for any missing or invalid fields.
*/
export function deserializeSearchState(queryString: string): SearchState {
const raw = qs.parse(queryString.replace(/^\?/, ""), QS_OPTIONS) as Record<string, unknown>;
const defaults = DEFAULT_SEARCH_STATE;
const rawFacets = (raw.facets ?? {}) as Record<string, unknown>;
const rawPrice = (rawFacets.price ?? {}) as { min?: unknown; max?: unknown };
return {
query: typeof raw.query === "string" ? raw.query : defaults.query,
sort: isValidSort(raw.sort) ? raw.sort : defaults.sort,
page: toPositiveInt(raw.page) ?? defaults.page,
perPage: toPositiveInt(raw.perPage) ?? defaults.perPage,
facets: {
brand: toStringArray(rawFacets.brand),
color: toStringArray(rawFacets.color),
size: toStringArray(rawFacets.size),
inStock: rawFacets.inStock === true || rawFacets.inStock === "true",
price: {
min: toPositiveInt(rawPrice.min) ?? defaults.facets.price.min,
max: toPositiveInt(rawPrice.max) ?? defaults.facets.price.max,
},
},
};
}
// ---- helpers ----
function isValidSort(v: unknown): v is SearchState["sort"] {
return ["relevance", "price_asc", "price_desc", "newest"].includes(v as string);
}
function toPositiveInt(v: unknown): number | undefined {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
}
function toStringArray(v: unknown): string[] {
if (Array.isArray(v)) return v.filter((x): x is string => typeof x === "string");
if (typeof v === "string") return [v];
return [];
}
This hook wraps React Router’s useSearchParams and useNavigate to provide a typed, debounced interface for reading and writing search state. The replace flag avoids polluting history during debounced text-input updates; discrete user actions like facet selection use push so the back button works correctly.
// hooks/useSearchState.ts
import { useCallback, useMemo, useRef } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { SearchState } from "@/types/search";
import {
serializeSearchState,
deserializeSearchState,
} from "@/lib/searchParams";
const DEBOUNCE_MS = 350;
export function useSearchState() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Derive current state from URL on every render (URL is the source of truth)
const state = useMemo(
() => deserializeSearchState(searchParams.toString()),
[searchParams]
);
/**
* Immediately write a new state to the URL.
* Use `replace: true` for text-input updates to avoid polluting history.
*/
const setStateImmediate = useCallback(
(next: SearchState, replace = false) => {
const qs = serializeSearchState(next);
navigate({ search: qs ? `?${qs}` : "" }, { replace });
},
[navigate]
);
/**
* Debounced URL write for text inputs.
* React state in the calling component updates immediately;
* the URL stabilizes after the user stops typing.
*/
const setStateDebouncedUrl = useCallback(
(next: SearchState) => {
if (debounceTimer.current) clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => {
const qs = serializeSearchState(next);
navigate({ search: qs ? `?${qs}` : "" }, { replace: true });
}, DEBOUNCE_MS);
},
[navigate]
);
/** Convenience: merge a partial update into current state */
const patchState = useCallback(
(patch: Partial<SearchState>, debounce = false) => {
const next = { ...state, ...patch, page: 1 }; // reset page on any filter change
if (debounce) {
setStateDebouncedUrl(next);
} else {
setStateImmediate(next);
}
},
[state, setStateDebouncedUrl, setStateImmediate]
);
return { state, patchState };
}
Next.js App Router’s useSearchParams is read-only; writes go through useRouter. The hook structure is otherwise identical, with router.replace for debounced updates and router.push for discrete facet changes.
// hooks/useSearchStateNext.ts (Next.js App Router variant)
"use client";
import { useCallback, useMemo, useRef } from "react";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import { SearchState } from "@/types/search";
import {
serializeSearchState,
deserializeSearchState,
} from "@/lib/searchParams";
const DEBOUNCE_MS = 350;
export function useSearchState() {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const state = useMemo(
() => deserializeSearchState(searchParams.toString()),
[searchParams]
);
const buildUrl = (next: SearchState) => {
const qs = serializeSearchState(next);
return qs ? `${pathname}?${qs}` : pathname;
};
const patchState = useCallback(
(patch: Partial<SearchState>, debounce = false) => {
const next = { ...state, ...patch, page: 1 };
if (debounce) {
if (debounceTimer.current) clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => {
router.replace(buildUrl(next), { scroll: false });
}, DEBOUNCE_MS);
} else {
// Discrete facet change: push to history so back button works
router.push(buildUrl(next), { scroll: false });
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[state, router, pathname]
);
return { state, patchState };
}
With the hook abstracted, components become simple: read from state, call patchState on user interactions. The text query uses a local useState for immediate UI feedback and triggers a debounced URL write; facet checkboxes write to the URL immediately.
// components/SearchBar.tsx
"use client";
import { useState, useEffect } from "react";
import { useSearchState } from "@/hooks/useSearchState";
export function SearchBar() {
const { state, patchState } = useSearchState();
// Local state mirrors URL query, updated immediately for responsive UX
const [inputValue, setInputValue] = useState(state.query);
// Keep local input in sync when URL changes externally (e.g. browser back)
useEffect(() => {
setInputValue(state.query);
}, [state.query]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setInputValue(value); // immediate UI update
patchState({ query: value }, true); // debounced URL write
};
return (
<input
type="search"
value={inputValue}
onChange={handleChange}
placeholder="Search products..."
aria-label="Search"
/>
);
}
// components/FacetGroup.tsx
"use client";
import { useSearchState } from "@/hooks/useSearchState";
interface FacetGroupProps {
field: "brand" | "color" | "size";
options: string[];
label: string;
}
export function FacetGroup({ field, options, label }: FacetGroupProps) {
const { state, patchState } = useSearchState();
const active = state.facets[field];
const toggle = (value: string) => {
const next = active.includes(value)
? active.filter((v) => v !== value)
: [...active, value];
// Discrete interaction: immediate URL write (no debounce)
patchState({ facets: { ...state.facets, [field]: next } });
};
return (
<fieldset>
<legend>{label}</legend>
{options.map((opt) => (
<label key={opt}>
<input
type="checkbox"
checked={active.includes(opt)}
onChange={() => toggle(opt)}
/>
{opt}
</label>
))}
</fieldset>
);
}
As an example, a product catalog application might use this pattern to power its main search interface. The search state includes free-text query, multiple facet groups (category, tags, status), a date range, sort order, and pagination. Because the full state lives in the URL, support team members can share a URL to reproduce exactly the filtered view a customer is looking at, and the application’s “Save Search” feature simply bookmarks the current URL—no server-side saved search infrastructure needed.
The debounce strategy is particularly important in this context. The catalog has hundreds of thousands of items and search queries are expensive. Debouncing URL writes for text inputs means that a fast typist producing a five-character query triggers one network request, not five. The router’s replace mode during debounce means the browser history only records the final stabilized query, so the back button returns the user to the pre-search state rather than stepping through each character they typed.
A secondary benefit is that the URL-driven approach integrates seamlessly with server-side rendering. In Next.js App Router, server components receive the initial search params as a prop. The page performs the initial search server-side using those params and streams results, then client components hydrate and take over URL synchronization from that baseline. The first page load shows real results without a client-side round trip to the search API.
One subtle requirement is resetting the page number to 1 whenever any filter or query changes. Without this, changing a facet while on page 5 of results could produce an empty page if the filtered result set has fewer pages. The patchState helper handles this automatically by merging page: 1 into every update. Page navigation itself is the one case that should not reset to page 1, so it calls the hook with an explicit page value.
Providing a “Clear all filters” button is straightforward: navigate to the URL with just the query string, or to the bare pathname if there is no query either. This is trivially implemented as patchState(DEFAULT_SEARCH_STATE), which serializes to an empty string and removes all query parameters.
The search state object maps directly to the API request. A thin adapter function converts SearchState to the request payload, keeping the transformation explicit and testable independently of URL logic.
// lib/searchApi.ts
import { SearchState } from "@/types/search";
export interface SearchRequest {
q: string;
filters: {
brand?: string[];
color?: string[];
size?: string[];
inStock?: boolean;
priceMin?: number;
priceMax?: number;
};
sortBy: string;
page: number;
hitsPerPage: number;
}
export function stateToApiRequest(state: SearchState): SearchRequest {
const { facets, query, sort, page, perPage } = state;
return {
q: query,
filters: {
...(facets.brand.length && { brand: facets.brand }),
...(facets.color.length && { color: facets.color }),
...(facets.size.length && { size: facets.size }),
...(facets.inStock && { inStock: true }),
...(facets.price.min !== 0 && { priceMin: facets.price.min }),
...(facets.price.max !== 10000 && { priceMax: facets.price.max }),
},
sortBy: sort,
page: page,
hitsPerPage: perPage,
};
}
Rendering a list of active filter badges (with individual remove buttons) is a read operation on the URL state. Iterate the facets object, emit a badge for each active value, and handle removal via patchState.
// components/ActiveFilters.tsx
"use client";
import { useSearchState } from "@/hooks/useSearchState";
export function ActiveFilters() {
const { state, patchState } = useSearchState();
const { facets } = state;
const removeArrayFacet = (field: "brand" | "color" | "size", value: string) => {
patchState({
facets: {
...facets,
[field]: facets[field].filter((v) => v !== value),
},
});
};
const hasFacets =
facets.brand.length ||
facets.color.length ||
facets.size.length ||
facets.inStock;
if (!hasFacets) return null;
return (
<div role="list" aria-label="Active filters">
{(["brand", "color", "size"] as const).flatMap((field) =>
facets[field].map((value) => (
<span key={`${field}:${value}`} role="listitem">
{field}: {value}
<button
onClick={() => removeArrayFacet(field, value)}
aria-label={`Remove ${field} filter: ${value}`}
>
×
</button>
</span>
))
)}
{facets.inStock && (
<span role="listitem">
In Stock
<button
onClick={() => patchState({ facets: { ...facets, inStock: false } })}
aria-label="Remove In Stock filter"
>
×
</button>
</span>
)}
</div>
);
}
Because the URL is always current, “Copy search link” functionality requires no special serialization—just copy window.location.href. To make URLs shorter or more readable, apply a URL shortener as a post-processing step rather than changing the core serialization approach.
useEffect that syncs local state from URL state (for external navigation events like browser back) is required. Without it, pressing back leaves the input showing the old value while the URL has reverted to the previous query.colors=red&colors=blue (repeated keys) but the frontend sends colors[]=red&colors[]=blue (brackets format), the backend parser may receive a single string value like "blue" instead of an array. Align the arrayFormat option with what the server’s query parser expects, or normalize in the API adapter.router.replace during debounce is critical. If router.push is used instead, a user typing a 10-character query creates 10 history entries, making the back button step through each character. Always use replace for debounced writes and push only for discrete interactions where back-button navigation is intentional.qs.parse returns everything as strings by default. A URL like ?page=3&inStock=true produces { page: "3", inStock: "true" }. The deserializer must explicitly coerce these to the correct types. Never pass raw qs.parse output directly to components or API calls without validation and coercion.facets.price.min as the flat key priceMin rather than facets[price][min].router.push defaults to scrolling to the top of the page. This is correct for pagination (user wants to see the new page from the top) but wrong for facet changes (user is actively browsing the filter panel). Pass { scroll: false } for facet updates and omit it (or pass { scroll: true }) for page changes.