URL + SearchParams together
You’ve learned how URL objects work and how URLSearchParams works. Now let’s see what happens when you use them together. There’s a behavior here that surprises a lot of people, and understanding it will save you from debugging sessions where things “just don’t update.”
The live connection
When you access .searchParams on a URL object, you get a live connection. Changes to one instantly affect the other:
const url = new URL("https://api.example.com/search?q=hello");
url.searchParams.set("page", "2");
url.searchParams.set("limit", "10");
console.log(url.href);
// "https://api.example.com/search?q=hello&page=2&limit=10" Look at what happened. We didn’t touch url.href or url.search directly. We only modified searchParams. But the URL’s href updated itself. They’re linked.
url.searchParams is not a copy of the query string. It’s a live view. When you call .set() on it, the URL object sees the change and updates its internal string representation immediately.
It works both ways
The connection goes in both directions:
const url = new URL("https://example.com?a=1&b=2");
url.search = "?brand=nike";
console.log(url.searchParams.get("brand")); // "nike"
console.log(url.searchParams.has("a")); // false ← gone!
console.log(url.searchParams.has("b")); // false ← gone! We replaced the entire search string by assigning to url.search. The old parameters a and b are gone because the new search string only contains brand=nike. And searchParams reflects that change immediately.
Setting url.search replaces the entire query string. Any previous parameters disappear.
Deleting works too
const url = new URL("https://api.com/data?temp=1&important=yes&debug=true");
url.searchParams.delete("temp");
url.searchParams.delete("debug");
console.log(url.href);
// "https://api.com/data?important=yes" We deleted two parameters through searchParams, and the URL’s href automatically dropped them.
Standalone vs connected
There’s a subtle but critical difference between a URLSearchParams you get from a URL and one you create on your own:
// CONNECTED — changes update the URL
const url = new URL("https://example.com?x=1");
url.searchParams.set("x", "2");
console.log(url.href); // "https://example.com/?x=2" ← updated!
// STANDALONE — no connection to any URL
const standalone = new URLSearchParams("x=1");
standalone.set("x", "2");
// There's no URL to update — it's just floating data
console.log(standalone.toString()); // "x=2" A standalone URLSearchParams (created with new URLSearchParams(...)) is not connected to any URL. It’s just a data structure, useful on its own, but with no URL to keep in sync. Only url.searchParams has the live link.
What does this mean in practice? If you need to modify a URL’s query string, always modify url.searchParams directly. Don’t create a separate URLSearchParams, modify it, and expect the URL to know about it. The live connection only exists when you access the URL’s own .searchParams property.
Real-world pattern: building API URLs
Here’s how experienced developers build API request URLs. This pattern comes up constantly in production code:
function buildApiUrl(endpoint, params = {}) {
const url = new URL(endpoint, "https://api.example.com");
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
url.searchParams.set(key, String(value));
}
}
return url.toString();
} Let’s walk through this function step by step.
endpoint is a relative path like "/products". params is a plain object like { category: "shoes", minPrice: 50 }.
First, we create a URL using the base URL pattern (relative path + base). Then we loop through the params object with Object.entries(), which gives us [key, value] pairs. For each pair, we check if the value is undefined or null. If it is, we skip it. Otherwise, we convert it to a string with String(value) and add it to the URL’s search params.
Here it is in action:
buildApiUrl("/products", {
category: "shoes",
minPrice: 50,
sort: "price_asc",
});
// "https://api.example.com/products?category=shoes&minPrice=50&sort=price_asc"
buildApiUrl("/products", {
category: "shoes",
minPrice: null, // skipped
maxPrice: undefined, // skipped
});
// "https://api.example.com/products?category=shoes" Null and undefined values are filtered out cleanly. No broken ?category=shoes&minPrice=null in the output.
Why this matters for @hectoday/http
This is almost exactly what @hectoday/http does internally in its buildRequest() function:
// Simplified from @hectoday/http source:
function buildRequest(path, options = {}) {
const method = (options.method ?? "GET").toUpperCase();
const url = new URL(path, "http://localhost");
if (options.query) {
for (const [k, v] of Object.entries(options.query)) {
if (Array.isArray(v)) {
for (const item of v) {
url.searchParams.append(k, item);
}
continue;
}
url.searchParams.set(k, v);
}
}
const headers = new Headers(options.headers);
return new Request(url, { method, headers });
} Notice how it uses append() for arrays and set() for single values. That’s exactly the distinction we learned two lessons ago. If the value is an array like ["js", "ts", "react"], it calls append() for each item so the key appears multiple times: tag=js&tag=ts&tag=react. For single values, it uses set().
Pattern: reading the current page URL
In a browser, you can parse the current page URL to read query parameters:
// If the user is on: https://mysite.com/products?category=shoes&page=3
const url = new URL(window.location.href);
const category = url.searchParams.get("category"); // "shoes"
const page = Number(url.searchParams.get("page")); // 3 window.location.href gives you the full URL of the current page as a string. We pass that to new URL() to get a URL object, then read the search params.
You now understand how URL and URLSearchParams work together. But there’s one more topic we need to cover before we look at how @hectoday/http uses all of this: encoding. What happens when your query values contain spaces, ampersands, or other special characters? That’s next.
If you modify url.searchParams, what happens to url.href?