Parse URL query parameters in JavaScript: URLSearchParams, its gotchas, and reacting to changes
You don't need a library or a regular expression to read a query string. URLSearchParams does it, works in every browser and in Node.js, and follows the same WHATWG URL spec everywhere:
const params = new URLSearchParams(location.search); // "?q=shoes&page=2"
params.get('page'); // "2"That's enough for the simple case. The rest of this post covers the details that cause real bugs. All the Node.js examples were run on node:24-slim and node:26-slim (identical output). The browser examples were run in headless Chromium 153, Firefox 155 and WebKit 26.6 using the official Playwright Docker image.
Reading parameters
A URL object has a searchParams property that's already parsed. Here is one URL with most of the tricky cases in it:
const url = new URL('https://shop.example/search?q=red+shoes&size=42&tag=sale&tag=new&empty=&flag¬e=50%25%20off');
const params = url.searchParams;
console.log(params.get('q')); // '+' decodes to a space
console.log(params.get('size'), typeof params.get('size'));
console.log(params.get('missing')); // not there → null
console.log(JSON.stringify(params.get('empty')), JSON.stringify(params.get('flag'))); // present but empty
console.log(params.has('flag'), params.has('missing'));
console.log(params.get('tag')); // only the first one
console.log(params.getAll('tag')); // all of them
console.log(params.get('note'));
console.log(params.size);
console.log(Object.fromEntries(params)); // repeated keys collapse to the last value$ node basics.mjs
red shoes
42 string
null
"" ""
true false
sale
[ 'sale', 'new' ]
50% off
7
{
q: 'red shoes',
size: '42',
tag: 'new',
empty: '',
flag: '',
note: '50% off'
}What that shows:
- Values are always strings.
sizeis"42", not42. - A missing key gives
null, and a key with no value gives"".?flagand?empty=both read as an empty string, so usehas()when you only care whether it's there. get()returns only the first of repeated keys. UsegetAll()for things like?tag=sale&tag=new.Object.fromEntries(params)loses data. Repeated keys collapse to the last value (tag: 'new'). It's fine for flat parameters, but don't use it as a general "parse everything" helper.- Percent-encoding is decoded for you (
50%25%20off→50% off), andsizecounts entries, including repeats.
The plus-sign gotcha
In a query string, + means a space. That's the application/x-www-form-urlencoded format HTML forms use, and URLSearchParams follows it. decodeURIComponent doesn't. So if anything builds a URL by gluing strings together, a literal plus sign gets lost:
const raw = 'q=C++%20tips&[email protected]';
console.log(new URLSearchParams(raw).get('q'));
console.log(new URLSearchParams(raw).get('email'));
console.log(decodeURIComponent('C++%20tips'));
// Building a query string: URLSearchParams encodes for you
console.log(new URLSearchParams({ q: 'C++ tips', email: '[email protected]' }).toString());
console.log(`q=${encodeURIComponent('C++ tips')}`);$ node plus.mjs
C tips
me [email protected]
C++ tips
q=C%2B%2B+tips&email=me%2Btest%40example.com
q=C%2B%2B%20tipsC++%20tips comes back as "C tips", with three spaces, and an email address with a + in it loses the plus. When you build URLs, let URLSearchParams (or encodeURIComponent) do the encoding. Both encode + as %2B, so it survives the round trip.
Turning strings into the values you want
Query parameters come from the user, so treat them like form input: convert, validate, and fall back to a default. Keeping that in one function makes the rest of the code simpler:
// Everything comes back as a string (or null). Convert on the way out.
function readFilters(search) {
const params = new URLSearchParams(search);
const page = Number.parseInt(params.get('page') ?? '1', 10);
return {
page: Number.isInteger(page) && page > 0 ? page : 1,
tags: params.getAll('tag'),
inStock: params.get('inStock') === 'true',
sort: ['price', 'name', 'newest'].includes(params.get('sort')) ? params.get('sort') : 'newest',
};
}
console.log(readFilters('?page=3&tag=a&tag=b&inStock=true&sort=price'));
console.log(readFilters('?page=-2&sort=<script>'));
console.log(readFilters(''));$ node typed.mjs
{ page: 3, tags: [ 'a', 'b' ], inStock: true, sort: 'price' }
{ page: 1, tags: [], inStock: false, sort: 'newest' }
{ page: 1, tags: [], inStock: false, sort: 'newest' }Note the second call: page=-2 and sort=<script> are rejected instead of reaching your code or your HTML.
Parsing a URL that might not be valid
new URL() throws on anything it can't parse, including relative URLs without a base. URL.canParse() and URL.parse() let you skip the try/catch:
console.log(URL.canParse('https://example.com/?a=1'), URL.canParse('/relative?a=1'));
console.log(URL.parse('/relative?a=1')); // null instead of throwing
console.log(URL.parse('/relative?a=1', 'https://example.com')?.href);
try {
new URL('/relative?a=1');
} catch (err) {
console.log(`${err.name}: ${err.message} (${err.code})`);
}$ node parse.mjs
true false
null
https://example.com/relative?a=1
TypeError: Invalid URL (ERR_INVALID_URL)URL.parse() returns null instead of throwing. It's available in Chrome 126, Firefox 126, Safari 18 and Node.js 22.1. URL.canParse() is a little older: Chrome 120, Firefox 115, Safari 17, Node.js 19.9.
Updating parameters
const url = new URL('https://shop.example/search?q=shoes&page=4&tag=sale');
url.searchParams.set('page', '1'); // replace
url.searchParams.append('tag', 'new'); // add another
url.searchParams.delete('tag', 'sale'); // remove only tag=sale
url.searchParams.sort(); // stable order, nicer cache keys
console.log(url.href);
console.log(url.search);$ node update.mjs
https://shop.example/search?page=1&q=shoes&tag=new
?page=1&q=shoes&tag=newdelete(name, value) with a second argument removes only that one pair and leaves other tag values alone. has(name, value) works the same way. Both need Chrome 117, Firefox 115, Safari 17 or Node.js 20.2. Older versions ignore the second argument and remove every tag.
In the browser, put the result back into the address bar without reloading the page:
// Read: the current query string as URLSearchParams.
const params = () => new URLSearchParams(location.search);
// Write: change one parameter without reloading the page.
// replaceState edits the current history entry; use pushState instead if
// the back button should step back through the changes.
function setParam(name, value, { push = false } = {}) {
const url = new URL(location.href);
if (value == null || value === '') url.searchParams.delete(name);
else url.searchParams.set(name, value);
history[push ? 'pushState' : 'replaceState'](history.state, '', url);
}Reacting when the query string changes
This is where the usual advice goes wrong. A common answer is to listen for popstate, but it only fires for back and forward navigation, not for pushState or replaceState. The Navigation API's currententrychange event fires for all of them. Here's what each engine reported when the test page called setParam() and then went back:
| Action | popstate | navigate | currententrychange |
|---|---|---|---|
replaceState (page=5) | no | yes (replace) | yes |
pushState (page=6) | no | yes (push) | yes |
history.back() | yes | yes (traverse) | yes |
The results were identical in Chromium 153, Firefox 155 and WebKit 26.6. So, to react to every change:
function onParamsChange(callback) {
if ('navigation' in window) {
navigation.addEventListener('currententrychange', () => callback(params()));
} else {
// Older browsers: popstate covers back/forward only, so also call
// callback() yourself after every setParam().
addEventListener('popstate', () => callback(params()));
}
}The Navigation API shipped in Chrome and Edge 102, Firefox 147 and Safari 26.2. Clicking an ordinary link to a different query string (<a href="?page=3">) is a full page load, so your script starts again and reads the new parameters on startup.
One more thing: this removes the need for the caching wrapper the first version of this post recommended. Creating a URLSearchParams is cheap, so reading location.search fresh each time is simpler and never stale.
On the server (Node.js)
In a Node.js http handler, req.url is just the path and query (/search?q=shoes), so give new URL() a base to resolve it against. The base doesn't matter if you only read the path and query:
import { createServer } from 'node:http';
const server = createServer((req, res) => {
// req.url is only the path + query, e.g. "/search?q=shoes&page=2"
const { pathname, searchParams } = new URL(req.url, 'http://localhost');
res.end(JSON.stringify({ pathname, q: searchParams.get('q'), page: searchParams.get('page') }));
}).listen(3000, async () => {
const res = await fetch('http://localhost:3000/search?q=red+shoes&page=2');
console.log(await res.text());
server.close();
});$ node server.mjs
{"pathname":"/search","q":"red shoes","page":"2"}URL and URLSearchParams are globals in Node.js. The older node:querystring module is still stable, and Node's docs say it's faster, but it isn't a standard API. Use URLSearchParams unless parsing is a measured hot spot, or when the same code also runs in the browser.