Query parameters are the glue that connects a URL to the state of your application.
They live after the ? in a URL and consist of key‑value pairs like page=2 or q=react.
In modern React Router (version 6.4 and above), query parameters are first‑class citizens: they can drive loaders, influence the data you fetch and even update your component state before anything renders.
I’ve been building React apps professionally for years, and every project eventually needs to filter a list, paginate results or preserve a search query. Each of those features relies on query parameters. In this guide, I’ll show you how to work with them in React Router v6/v7, highlight best practices, and share a few advanced tips, including typed query parsing with Zod.
TL;DR – Quick start
If you’re in a hurry, here’s the cheat sheet:
- Get and set query parameters with
useSearchParams(): callsearchParams.get('q')to read a value andsetSearchParams()with a clonedURLSearchParamsto update it. The hook returns a stable instance so you can includesearchParamssafely in your dependency array. - Use loaders for server‑side queries: Data Routers can read
new URL(request.url).searchParamsin theirloader, fetch data and render your component only after the promise resolves. It’s the fastest way to keep UI and URL in sync. - Debounce user input: when updating the query string from a text input, debounce the changes so you don’t flood the history with entries or re‑fetch on every keystroke. A custom hook demonstrates this pattern below.
- Keep keys short and descriptive: parameters such as
q,sortandpageare easy to remember. Don’t store sensitive data in the URL and always encode user input. - Type‑safe parsing: for TypeScript apps, parse and validate parameters using a schema (e.g. Zod) so you never compare strings to numbers by accident. I show an example later.
So, what exactly are query parameters?
Query parameters (also called search parameters) are optional pieces of information appended to a URL after a question mark. Multiple parameters are separated by an ampersand and written as key=value pairs. For example:
http://example.com/products?category=shoes&sort=price_asc&page=3
In the snippet above, the parameters category, sort and page refine the /products route. Because query parameters are part of the URL, they can be bookmarked, shared or crawled by search engines.

Under the hood React Router uses the HTML5 History API to manipulate the browser’s address bar and session history. Several router implementations exist:
- BrowserRouter – the default declarative router for traditional single‑page applications. It uses the browser’s history stack and works well for most client‑side apps.
- HashRouter – uses the URL’s hash fragment (
#) instead of the path. It’s useful when you can’t configure server‑side rewrite rules. - MemoryRouter – keeps routing state in memory; useful for testing or in environments without a real URL (e.g. React Native).
- StaticRouter / ServerRouter – used for server‑side rendering. In Data Router mode, you’ll use
createStaticHandlerandcreateStaticRouterinstead.
React Router v7 (released in 2024) continues to refine the v6 API while adding stronger TypeScript support and stability. Existing hooks like useSearchParams, useLocation and useNavigate are unchanged. Newer features such as view transitions, data prefetching and route‑level error boundaries are also available.
How do query parameters differ from route parameters?
Route parameters live inside the path (e.g. /users/:userId) and identify a specific resource. Query parameters, on the other hand, are optional and describe UI state—such as filters, sorts, or pagination.
You can add or remove them without changing your route definitions. They’re ideal for things like ?tab=activity&page=2, whereas a route parameter would capture /users/42.
Differences Between Query Params and Route Params
Route parameters and query parameters both allow data to travel through a URL, but they serve different purposes:
| Purpose | Query Parameters | Route Parameters |
|---|---|---|
| Used for… | Optional UI state such as filters, sorts, searches, page numbers and feature toggles | Identifying specific resources (e.g., product ID, user ID) |
| Placement | After the route’s path, prefaced with ? and separated by & | Inside the path itself, declared with a colon (:) |
| Example | /users?tab=activity&page=2 | /users/:userId → /users/42 |
| Access in React Router | useSearchParams() or useLocation().search | useParams() |
| Multiple values | Use repeated keys (?brand=1&brand=2) or encode manually | Not applicable – there is one value per route param |
In short, query parameters should hold UI state that is optional and shareable, while route parameters should hold identifiers that are integral to the path. Query parameters can be added or removed without changing your route definitions.
Setting up React Router for query parameters
Before you start reading and writing search parameters, you need a router. For simple client‑side apps, use the classic BrowserRouter:
// App.jsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import ProductsPage from './ProductsPage';
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/products" element={<ProductsPage />} />
{/* other routes */}
</Routes>
</BrowserRouter>
);
}
JSXThis pattern works when you fetch data in your components and rely on hooks like useEffect or useLoaderData. If you need to fetch data before a component renders (for example, to avoid layout shifts and improve SEO), use a Data Router. Data Routers let you declare loaders alongside routes and read search parameters from new URL(request.url).searchParams.
// main.tsx
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import ProductsPage, { loader as productsLoader } from './ProductsPage';
const router = createBrowserRouter([
{
path: '/products',
element: <ProductsPage />, // UI component
loader: productsLoader // runs before render and reads search params
},
]);
export default function App() {
return <RouterProvider router={router} />;
}
JSXHere, the loader can parse request.url.searchParams, fetch your data, and pass it to the component via useLoaderData. This ensures your component sees the latest data without flickering.
useSearchParams example — get, set, and delete query parameters
The simplest way to read and update search parameters in React Router v6/v7 is the useSearchParams hook. It returns a URLSearchParams instance (searchParams) and a setSearchParams() function. Since searchParams is stable (it won’t change unless the URL changes), you can safely include it in a useEffect dependency array.
import { useSearchParams } from 'react-router-dom';
export default function QueryExample() {
const [searchParams, setSearchParams] = useSearchParams();
// Read parameters
const q = searchParams.get('q'); // string | null
const sort = searchParams.get('sort') ?? 'relevance';
const page = Math.max(1, Number(searchParams.get('page') || '1'));
// Update parameters
function updateSort(sortValue) {
const next = new URLSearchParams(searchParams);
next.set('sort', sortValue);
setSearchParams(next);
}
// Remove a parameter
function removeParam(name) {
const next = new URLSearchParams(searchParams);
next.delete(name);
setSearchParams(next);
}
}
JSXYou must clone the searchParams object (new URLSearchParams(searchParams)) before modifying it. If you mutate the original object directly, React Router won’t detect any changes and your component won’t rerender.scientyficworld.org.
Debounced search with useSearchParams
Users type quickly, but you probably don’t want to update the URL on every keystroke. A debounce helps by waiting for a pause in typing before writing to the query string. Here’s a reusable hook:
import { useSearchParams } from 'react-router-dom';
import { useEffect, useRef } from 'react';
export function useDebouncedSearchParam(key, delay = 300) {
const [searchParams, setSearchParams] = useSearchParams();
const timeoutRef = useRef();
const value = searchParams.get(key) ?? '';
function setValue(nextValue) {
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
const next = new URLSearchParams(searchParams);
next.set(key, nextValue);
setSearchParams(next);
}, delay);
}
useEffect(() => () => clearTimeout(timeoutRef.current), []);
return [value, setValue];
}
JSXThis pattern waits delay milliseconds after the user stops typing before committing the new value. It prevents an explosion of history entries and unnecessary data requests.
Reading query params in loaders (Data Router)
When using a Data Router, loaders execute before your component renders. That means you can read search parameters, fetch data, and pass results into your component via useLoaderData(). Here’s an example that fetches products based on a search query, sort order, and page number:
// ProductsPage.tsx
import { useLoaderData } from 'react-router-dom';
export async function loader({ request }) {
const url = new URL(request.url);
const q = url.searchParams.get('q') ?? '';
const sort = url.searchParams.get('sort') ?? 'relevance';
const page = Math.max(1, Number(url.searchParams.get('page') || '1'));
const data = await fetchProducts({ q, sort, page });
return { data, q, sort, page };
}
export default function ProductsPage() {
const { data, q, sort, page } = useLoaderData();
return <ProductsTable data={data} q={q} sort={sort} page={page} />;
}
JSXBy placing the logic in a loader, you ensure that both the URL and the fetched data are always aligned; the component will only render after the loader promise resolves. That’s good for SEO (search engines can index the final HTML) and eliminates flickers.
Implementing filters and pagination
Two common tasks require query parameters: filtering a list and paginating results.
Below is a simple filter form that writes its value to a filter parameter and resets the page number to 1 whenever the filter changes:
import { useSearchParams } from 'react-router-dom';
export function FilterForm() {
const [searchParams, setSearchParams] = useSearchParams();
const filterValue = searchParams.get('filter') ?? '';
function handleSubmit(e) {
e.preventDefault();
const next = new URLSearchParams(searchParams);
next.set('filter', filterValue);
next.set('page', '1'); // reset pagination on filter change
setSearchParams(next);
}
return (
<form onSubmit={handleSubmit}>
<label>
Filter:
<input
type="text"
value={filterValue}
onChange={e => setSearchParams(prev => {
const next = new URLSearchParams(prev);
next.set('filter', e.target.value);
return next;
})}
/>
</label>
<button type="submit">Apply</button>
</form>
);
}
JSXFor pagination, store the current page in a page parameter and provide buttons that update it. Remember to coerce the page to a number and clamp it to the valid range:
export function PaginationControls({ totalPages }) {
const [searchParams, setSearchParams] = useSearchParams();
const currentPage = Math.max(1, Math.min(Number(searchParams.get('page') || '1'), totalPages));
function goTo(page) {
const next = new URLSearchParams(searchParams);
next.set('page', String(page));
setSearchParams(next, { replace: true }); // avoid history bloat
}
return (
<div>
<button disabled={currentPage <= 1} onClick={() => goTo(currentPage - 1)}>
Previous
</button>
<span>Page {currentPage} / {totalPages}</span>
<button disabled={currentPage >= totalPages} onClick={() => goTo(currentPage + 1)}>
Next
</button>
</div>
);
}
JSXUse loaders to read the page parameter and fetch only the relevant slice of data. When the user navigates to a new page, the loader runs again with the updated value.
Best practices for query parameters
Over the years, I’ve learned a few lessons — many echoed in the official docs and summarised here:
- Never store sensitive data in the URL. Query strings are logged and cached by servers, proxies and browsers.
- Use concise, meaningful keys. Names like
q,sortandpagecommunicate intent without being verbose. - Prefer built‑in APIs. Use
setSearchParams()ornavigate()instead of manually manipulatingwindow.location. They integrate with React Router’s history and trigger re‑renders correctly. - Encode user input. Apply
encodeURIComponent()to values to avoid breaking the URL and decode them when reading. - Reset pagination when filters change. Nothing is more confusing than changing a filter and staying on page 3 with no results.
- Use the
{ replace: true }option for transient updates. When updating the search box or turning pages quickly, replace the current history entry to avoid clogging the history stack. - Validate and coerce types. Always parse numbers with
Number(), booleans by comparing strings and arrays by using repeated keys or JSON encoding. - Make the URL your source of truth. Derive your component state from
searchParamsinstead of duplicating it; this avoids race conditions and makes the UI predictable. - Consider a helper library for complex types. Packages like
nuqsconvert strings to booleans, numbers and arrays automatically. - For SEO, prefer Data Routers. Loaders fetch data before render so that both users and crawlers see the final page without client‑side flicker.
Type‑safe query parameters with TypeScript and Zod
Working in TypeScript, you’ll want to ensure your query parameters have the right shape. Here’s how to parse and validate them using the Zod schema library:
import { z } from 'zod';
// Define a schema for expected query parameters
const QuerySchema = z.object({
q: z.string().optional(),
sort: z.enum(['relevance', 'price_asc', 'price_desc']).default('relevance'),
page: z.preprocess((v) => Number(v), z.number().int().min(1).default(1)),
});
export async function loader({ request }) {
const url = new URL(request.url);
const params = Object.fromEntries(url.searchParams.entries());
const parsed = QuerySchema.parse(params);
const { q, sort, page } = parsed;
const data = await fetchProducts({ q: q ?? '', sort, page });
return { data, q, sort, page };
}
JSXZod’s preprocess converts the string page to a number, then validates it as an integer ≥ 1. By defining a schema, you get strong type checking and sensible defaults. You can swap Zod for nuqs if you prefer its built‑in NumberParam, BooleanParam, etc.; they integrate nicely with React Router v7.
Migration notes for older React Router versions
If you’re migrating from React Router v5 or earlier, you might have used the query-string package or parsed location.search manually. In v6+, useSearchParams() handles most of that for you. The major changes are:
- Hooks instead of render props (
<Route>no longer passesmatchandlocationby default). - The
historyobject is encapsulated inside the router; usenavigate()to push or replace. - Data Routers unify routing and data fetching so you can stop calling
useEffect()for initial requests.
You can still access the raw query string via useLocation().search and parse it with your own library, but I recommend embracing the new APIs to simplify your code and improve SEO.
Performance considerations & SEO implications
Long URLs can cause subtle problems. Most browsers support URLs up to around 2 MB, but many servers truncate at 8–16 KB. Keep your query strings short. Also, caching proxies treat URLs with different query strings as distinct resources, so excessive variation can bust caches. When you build an infinite scroll or filter UI, avoid encoding large payloads in the URL—stick to small tokens or IDs.
From an SEO perspective, Data Routers with loaders allow you to prefetch data server‑side and return fully rendered HTML. That improves the Largest Contentful Paint (LCP) and ensures search engines index the correct content. On the other hand, heavy client‑side JavaScript, third‑party widgets and multiple trackers can delay rendering and hurt Core Web Vitals. Audit your scripts, defer non‑critical libraries and preload your hero image. Your content is valuable; don’t let ads get in the way.
Conclusion — make query params work for you
Query parameters are a deceptively simple tool that unlocks powerful UX patterns: shareable filters, bookmarkable states and seamless pagination. In this guide, you learned how to read and write them using useSearchParams(), orchestrate them in loaders, debounce user input and enforce types with Zod. You also saw why Data Routers improve SEO and how small details (like resetting pagination or replacing history entries) have outsized effects on user experience.
The next time you build a React app, treat the URL as the single source of truth. Keep your parameters concise, validate them, and let React Router handle the navigation. Your users—and search engines—will thank you.
FAQs:
How can I synchronise component state with query parameters without causing infinite loops?
Use the URL as the single source of truth. Read the current value with useSearchParams(), and update it via setSearchParams() or navigate(). If you need to mirror the value in the component state (for controlled inputs), debounce updates, and write to the URL only after the user stops typing. Avoid storing the same piece of state in both the URL and useState().
What’s the best way to handle complex query parameters such as arrays or objects?
For arrays of primitives, repeat the key (?tag=react&tag=redux). For more complex structures, encode them using JSON.stringify() and decode with JSON.parse(). Libraries like nuqs provide helpers (ArrayParam, NumberParam, etc.) that handle serialisation and parsing for you.
Can query parameters be type‑checked in React Router?
React Router itself doesn’t enforce types for search params. To ensure you’re working with numbers or booleans, parse and validate the strings manually or use a schema validator like Zod. A simple schema (see “Type‑safe query parameters” above) will catch mistakes at compile time and provide default values.
How do I manage default values for query parameters?
When your component or loader runs, check if parameters are present and valid. If not, programmatically set a sensible default using setSearchParams() or navigate() with { replace: true } to avoid creating a new history entry.
What are the SEO implications of query parameters?
Search engines treat distinct query strings as separate URLs. If you have multiple variations (e.g., ?page=1 and ?page=2), make sure to use canonical tags or a pagination component that specifies rel="next" and rel="prev". Preloading data with loaders improves LCP and ensures crawlers index the fully rendered content.
Looking for more? Check out my deep dive on React Router here, learn how to manage global state with React Redux, and explore type‑safe forms with Zod.