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

  1. The constructor receives input and, if provided, base.
  2. If input is not already an absolute URL (it has no scheme), Node resolves it against base using the same algorithm browsers use for relative links — same rules as <a href> resolution in HTML.
  3. The scheme, userinfo, host, and port are extracted and validated; hostnames are lowercased and, if needed, punycode-encoded.
  4. The path is normalized (resolving . and .. segments for hierarchical schemes) and percent-encoded where required.
  5. The query string becomes both the raw search string and a lazily-created URLSearchParams object backing searchParams — mutating one keeps the other in sync.
  6. If any step fails — an unparseable scheme, an invalid port, a relative input with no base — the constructor throws a TypeError synchronously. 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() / URLSearchParams for new code — treat url.parse() and url.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.url in an HTTP handler, always pass a base built from the request’s Host header — req.url is never an absolute URL on its own.
  • Wrap new URL(...) in a try/catch whenever 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+) or fileURLToPath(import.meta.url) paired with path.dirname() to replace __dirname, which does not exist in ES modules.
  • Use URLSearchParams‘s getAll() when a key can legitimately repeat (e.g. ?tag=a&tag=b); get() only returns the first match.
  • Compare origins with url.origin rather than string-matching protocol and host separately — 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 with new URL(), and logs the hostname, port, path, and every query parameter as key: value pairs.
  • 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 using URL and URLSearchParams (don’t concatenate strings by hand).
  • Using node:http, write a tiny server that reads a page query 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 URL and URLSearchParams classes, which follow the WHATWG URL Standard — the same spec browsers use.
  • new URL(input, base) resolves relative input against base and throws a TypeError on invalid or relative-without-base input.
  • URLSearchParams handles reading, adding, replacing, and correctly encoding query string parameters — never build query strings by hand.
  • The node:url module still provides useful helpers like fileURLToPath() and pathToFileURL(), mainly for the ESM __dirname replacement.
  • url.parse() and url.format() are deprecated legacy APIs — avoid them in new code in favor of the WHATWG classes.
  • When parsing req.url in an HTTP server, always supply a base derived from the request’s Host header, since req.url alone is never absolute.