The url Module & WHATWG URL
Almost every server, client, and CLI tool eventually needs to take a URL apart, change a piece of it, or build one from scratch. Node.js exposes two ways to do this: the modern, standards-based WHATWG URL API — the same URL and URLSearchParams classes you already know from the browser — and a legacy url.parse() function that predates them. New code should reach for the WHATWG API almost every time, because it is consistent across platforms, safer about parsing, and fully spec-compliant.
The WHATWG URL class
The URL class is a global in Node.js, so you do not even need to import anything to use it. You construct it from an absolute URL string (optionally with a base), and it parses the input into structured, individually accessible components.
const url = new URL('https://user:[email protected]:8443/v2/users?role=admin&active=true#results');
console.log(url.protocol); // https:
console.log(url.hostname); // api.example.com
console.log(url.port); // 8443
console.log(url.pathname); // /v2/users
console.log(url.search); // ?role=admin&active=true
console.log(url.hash); // #results
console.log(url.username); // user
console.log(url.origin); // https://api.example.com:8443
Output:
https:
api.example.com
8443
/v2/users
?role=admin&active=true
#results
user
https://api.example.com:8443
The components are not read-only — assigning to them mutates and re-serializes the URL automatically, which makes building URLs from a template both safe and readable.
const url = new URL('https://example.com');
url.pathname = '/search';
url.searchParams.set('q', 'node url');
url.searchParams.set('page', '2');
console.log(url.href);
Output:
https://example.com/search?q=node+url&page=2
Constructing a
URLfrom a relative path throws aTypeErrorunless you supply a base:new URL('/path', 'https://example.com'). Wrap untrusted input inURL.canParse(input)(Node 19.9+) to validate without try/catch.
URLSearchParams and query strings
The search property gives you the raw query string, but you rarely want to parse that by hand. Every URL exposes a live searchParams object — an instance of the global URLSearchParams class — that handles percent-encoding, repeated keys, and ordering for you.
const url = new URL('https://shop.example.com/items?tag=sale&tag=new&limit=20');
console.log(url.searchParams.get('limit')); // 20
console.log(url.searchParams.getAll('tag')); // [ 'sale', 'new' ]
console.log(url.searchParams.has('tag')); // true
url.searchParams.append('tag', 'featured');
url.searchParams.delete('limit');
console.log(url.search); // ?tag=sale&tag=new&tag=featured
Output:
20
[ 'sale', 'new' ]
true
?tag=sale&tag=new&tag=featured
You can also use URLSearchParams standalone — to parse a query string, build a request body, or iterate every pair.
const params = new URLSearchParams({ q: 'hello world', safe: 'true' });
console.log(params.toString()); // q=hello+world&safe=true
for (const [key, value] of params) {
console.log(`${key} -> ${value}`);
}
Output:
q=hello+world&safe=true
q -> hello world
safe -> true
URLSearchParamsencodes spaces as+(form-encoding) rather than%20. Both decode correctly, but if a downstream system requires%20, build the query throughurl.pathname/manual encoding withencodeURIComponentinstead.
File URLs: fileURLToPath and pathToFileURL
Inside ES modules there is no __dirname or __filename; instead you get import.meta.url, which is a file:// URL string. Converting between file URLs and ordinary file-system paths is where fileURLToPath and pathToFileURL from node:url come in. Never strip the file:// prefix by hand — doing so breaks on Windows (drive letters) and on paths containing spaces or non-ASCII characters.
import { fileURLToPath, pathToFileURL } from 'node:url';
import { dirname, join } from 'node:path';
// Recreate __filename / __dirname in an ES module
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
console.log(__dirname);
// Go the other way: build a file URL from a path
const fileUrl = pathToFileURL(join(__dirname, 'data', 'config.json'));
console.log(fileUrl.href);
Output:
/home/alice/project/src
file:///home/alice/project/src/data/config.json
This conversion is essential when you dynamically import() a local file by path, since the dynamic import specifier must be a valid URL.
Legacy url.parse vs the WHATWG API
Before the WHATWG URL class, Node offered url.parse(), which returns a plain object. It is now legacy and carries real footguns: it does not percent-decode consistently, is lenient about malformed input in ways that have caused security bugs, and exposes a different property shape. Use it only when maintaining old code.
import url from 'node:url';
// Legacy — avoid in new code
const legacy = url.parse('https://example.com/p?x=1', true);
console.log(legacy.query); // { x: '1' } (when parseQueryString = true)
// Modern equivalent
const modern = new URL('https://example.com/p?x=1');
console.log(Object.fromEntries(modern.searchParams)); // { x: '1' }
Output:
[Object: null prototype] { x: '1' }
{ x: '1' }
| Aspect | WHATWG URL | Legacy url.parse() |
|---|---|---|
| Standard | WHATWG URL spec (browser-compatible) | Node-specific, historical |
| Availability | Global, no import needed | node:url, deprecated |
| Query handling | Live searchParams object | Plain object via parseQueryString |
| Encoding | Spec-correct, consistent | Inconsistent, error-prone |
| Status | Recommended | Legacy / discouraged |
Best Practices
- Default to the global
URLandURLSearchParamsclasses; treaturl.parse()as maintenance-only. - Validate input with
URL.canParse(str)(or a try/catch) before constructing, since invalid URLs throw. - Always pass a base URL when the input may be relative:
new URL(path, base). - Use
searchParams.set/append/getAllinstead of string-concatenating query parameters by hand. - Convert between
file://URLs and paths withfileURLToPath/pathToFileURL— never manual string surgery. - Combine
URLwithnode:pathfor file work, but never usenode:pathto manipulate the path portion of a network URL.