The url Module and URL API
Every request your Node.js server handles arrives with a URL, and almost every HTTP client you write has to build one. Node’s built-in url module — and specifically the modern WHATWG URL and URLSearchParams classes — gives you a reliable, spec-compliant way to parse, build, validate, and manipulate URLs without hand-rolled regular expressions or fragile string concatenation. This lesson covers the modern URL API in depth, shows the helper functions the node:url module still provides for file-path/URL conversion, and contrasts everything with the older, deprecated url.parse() API so you know what to avoid in new code.
Overview: How URL Parsing Works in Node.js
A URL is made of well-defined parts: scheme://username:password@host:port/pathname?search#hash. Node.js parses URLs according to the WHATWG URL Standard — the exact same specification that browsers implement — via the global URL and URLSearchParams classes. These have been available as globals since Node 10, so in modern code you almost never need to require or import anything just to construct a new URL(...). The separate node:url module still exists, but today it’s mostly used for a handful of helper functions that convert between file paths and file:// URLs, and for the older, deprecated url.parse()/url.format() pair.
Under the hood, the WHATWG parser is stricter and more consistent than the legacy one. It normalizes hostnames (including converting internationalized domain names to their ASCII-compatible xn-- punycode form via ICU), percent-encodes characters in the path, query, and fragment according to fixed rules, and rejects malformed input by throwing a TypeError instead of silently producing a half-broken object. This is exactly why the URL Standard replaced url.parse(): the old parser behaved inconsistently across edge cases (unicode hosts, missing slashes, backslashes treated as forward slashes only sometimes), which caused real security bugs — parsers and browsers occasionally disagreed on what a URL even meant, opening the door to request-smuggling and open-redirect style issues. URLSearchParams is the companion class for the query string: it implements the same application/x-www-form-urlencoded serialization rules as browsers, supports multiple values for the same key, and is iterable, so you can loop over it directly instead of parsing a=1&b=2 by hand.
Syntax
The general form for creating a URL object is:
new URL(input, base);
| Part | Description |
|---|---|
input |
An absolute URL string, or a relative one if base is given. |
base |
Optional. A base URL (string or URL) that input is resolved against, like an HTML <base> tag. Required if input is not absolute. |
Once you have a URL instance, these properties are the ones you’ll use constantly:
| Property | Meaning |
|---|---|
href |
The full, normalized URL string. |
origin |
Protocol + host, e.g. https://example.com. |
protocol |
The scheme, including the trailing colon, e.g. https:. |
username / password |
Userinfo, if present in the URL. |
host / hostname |
host includes the port; hostname does not. |
port |
Port as a string, empty if the default for the scheme. |
pathname |
The path, starting with /. |
search |
The raw query string, including the leading ?. |
searchParams |
A live URLSearchParams instance for reading/writing the query. |
hash |
The fragment, including the leading #. |
The node:url module also exports a few standalone helpers worth knowing:
| Function | Purpose |
|---|---|
fileURLToPath(url) |
Converts a file:// URL (like import.meta.url) into a normal filesystem path. |
pathToFileURL(path) |
Converts a filesystem path into a file:// URL. |
urlToHttpOptions(url) |
Converts a URL into an options object suitable for http.request(). |
domainToASCII(domain) |
Punycode-encodes an internationalized domain name. |
url.parse() / url.format() |
Legacy, deprecated. Avoid in new code — use new URL() instead. |
Examples
Example 1: Parsing a URL and reading its parts
const myURL = new URL("https://user:pass@sub.example.com:8080/path/to/page?id=42&sort=asc#section-2");
console.log(myURL.href);
console.log(myURL.protocol);
console.log(myURL.hostname);
console.log(myURL.port);
console.log(myURL.pathname);
console.log(myURL.search);
console.log(myURL.searchParams.get("id"));
console.log(myURL.hash);
Output:
https://user:pass@sub.example.com:8080/path/to/page?id=42&sort=asc#section-2
https:
sub.example.com
8080
/path/to/page
?id=42&sort=asc
42
#section-2
The constructor does all the parsing in one pass and throws immediately if the string isn’t a valid absolute URL. Notice that searchParams.get("id") already gives you the decoded value "42" — you never need to split the query string yourself.
Example 2: Building and modifying query strings with URLSearchParams
const params = new URLSearchParams("category=books&sort=price");
params.append("sort", "title");
params.set("page", "2");
params.delete("category");
for (const [key, value] of params) {
console.log(`${key}=${value}`);
}
console.log(params.toString());
Output:
sort=price
sort=title
page=2
sort=price&sort=title&page=2
append() adds a value without removing existing ones for that key (so sort ends up with two values), set() replaces all values for a key (or adds it if missing), and delete() removes a key entirely. Because URLSearchParams is iterable, you can use for...of, spread it into an array, or call .toString() to get a correctly encoded query string ready to append to a URL.
Example 3: Parsing request URLs in an HTTP server
import http from "node:http";
const server = http.createServer((req, res) => {
const requestUrl = new URL(req.url, `http://${req.headers.host}`);
if (requestUrl.pathname === "/search") {
const term = requestUrl.searchParams.get("q") ?? "";
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ query: term }));
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found" }));
});
server.listen(3000, () => {
console.log("Server running at http://localhost:3000");
});
Output: after starting the server, requesting /search?q=node returns {"query":"node"}; any other path returns a 404 JSON body.
This is the pattern you’ll use constantly: req.url from Node’s http module is only a path plus query string (there’s no scheme or host on it), so it isn’t a valid absolute URL by itself. Passing it as input alongside a constructed base (built from the Host header) gives you a full URL object with a working searchParams.
Under the Hood: Order of Operations
- The constructor receives
inputand, if provided,base. - If
inputis not already an absolute URL (it has no scheme), Node resolves it againstbaseusing the same algorithm browsers use for relative links — same rules as<a href>resolution in HTML. - The scheme, userinfo, host, and port are extracted and validated; hostnames are lowercased and, if needed, punycode-encoded.
- The path is normalized (resolving
.and..segments for hierarchical schemes) and percent-encoded where required. - The query string becomes both the raw
searchstring and a lazily-createdURLSearchParamsobject backingsearchParams— mutating one keeps the other in sync. - If any step fails — an unparseable scheme, an invalid port, a relative
inputwith nobase— the constructor throws aTypeErrorsynchronously. There’s no silent partial result to accidentally trust.
Common Mistakes
Mistake 1: Using the deprecated url.parse()
url.parse() still works, but it’s officially deprecated: it’s not spec-compliant, mishandles some malformed and unicode URLs, and returns a legacy Url object instead of the standard interface.
const url = require("node:url");
const parsed = url.parse("https://example.com/products?id=9", true);
console.log(parsed.query.id);
Prefer the WHATWG URL class instead:
const parsed = new URL("https://example.com/products?id=9");
console.log(parsed.searchParams.get("id"));
Mistake 2: Parsing a relative URL without a base
req.url, links scraped from HTML, and paths from config files are frequently relative. Passing one straight into new URL() without a base throws.
const link = new URL("/dashboard?tab=billing");
console.log(link.pathname);
This throws TypeError [ERR_INVALID_URL]: Invalid URL because /dashboard?tab=billing has no scheme and there’s nothing to resolve it against. Always supply a base when the input might be relative:
const link = new URL("/dashboard?tab=billing", "https://app.example.com");
console.log(link.pathname);
Mistake 3: Building query strings by string concatenation
Manually concatenating query parameters skips percent-encoding entirely, so any &, =, or space inside a value corrupts the query string.
const search = "node.js url&query";
const endpoint = "https://api.example.com/search?q=" + search + "&limit=10";
console.log(endpoint);
The literal & inside search silently creates an extra, unintended query parameter. Use URLSearchParams so every value is encoded correctly:
const endpoint = new URL("https://api.example.com/search");
endpoint.searchParams.set("q", "node.js url&query");
endpoint.searchParams.set("limit", "10");
console.log(endpoint.toString());
Best Practices
- Always use
new URL()/URLSearchParamsfor new code — treaturl.parse()andurl.format()as legacy, don’t reach for them. - Never build query strings with string concatenation; use
searchParams.set()/append()so encoding is handled for you. - When parsing
req.urlin an HTTP handler, always pass a base built from the request’sHostheader —req.urlis never an absolute URL on its own. - Wrap
new URL(...)in atry/catchwhenever the input comes from user input, a config file, or the network — malformed input throws synchronously. - In ESM files, use
import.meta.dirname(Node 20.11+) orfileURLToPath(import.meta.url)paired withpath.dirname()to replace__dirname, which does not exist in ES modules. - Use
URLSearchParams‘sgetAll()when a key can legitimately repeat (e.g.?tag=a&tag=b);get()only returns the first match. - Compare origins with
url.originrather than string-matchingprotocolandhostseparately — it’s less error-prone for things like CORS checks.
Practice Exercises
- Write a script that takes a URL string like
"https://shop.example.com:8443/items/42?ref=email&utm_source=newsletter", parses it withnew URL(), and logs the hostname, port, path, and every query parameter askey: valuepairs. - Write a function
buildApiUrl(base, path, paramsObject)that takes a base URL string, a path, and a plain object of query parameters, and returns a fully-formed, correctly encoded URL string usingURLandURLSearchParams(don’t concatenate strings by hand). - Using
node:http, write a tiny server that reads apagequery parameter from the request URL (defaulting to"1"if missing) and responds with JSON like{"page": "3"}. Test it by requesting different?page=values.
Summary
- Node’s modern URL parsing is done with the global
URLandURLSearchParamsclasses, which follow the WHATWG URL Standard — the same spec browsers use. new URL(input, base)resolves relative input againstbaseand throws aTypeErroron invalid or relative-without-base input.URLSearchParamshandles reading, adding, replacing, and correctly encoding query string parameters — never build query strings by hand.- The
node:urlmodule still provides useful helpers likefileURLToPath()andpathToFileURL(), mainly for the ESM__dirnamereplacement. url.parse()andurl.format()are deprecated legacy APIs — avoid them in new code in favor of the WHATWG classes.- When parsing
req.urlin an HTTP server, always supply a base derived from the request’sHostheader, sincereq.urlalone is never absolute.
