ufo vs url-parse vs whatwg-url: URL Parsing and Manipulation in Node.js (2026)
TL;DR
ufo is the UnJS URL utility — safe URL manipulation with encoding preservation, query handling, and URL normalization, used by Nuxt, H3, and Nitro. url-parse is the universal URL parser — works in Node.js and browsers, same API everywhere, parses all URL components. whatwg-url is the WHATWG URL Standard implementation — full spec compliance, used by Node.js internally, URL and URLSearchParams polyfill. In 2026: ufo for safe URL manipulation in server frameworks, the built-in URL class for standard parsing, url-parse for legacy browser support.
Key Takeaways
- ufo: ~10M weekly downloads — UnJS, safe URL joins, encoding-aware, query utilities
- url-parse: ~15M weekly downloads — universal parser, same API in Node.js + browser
- whatwg-url: ~10M weekly downloads — full WHATWG URL Standard, powers Node.js URL class
- The built-in
URLclass (based on WHATWG spec) handles most URL parsing needs - ufo solves edge cases: double-encoding, trailing slashes, safe URL joins
- url-parse is useful for environments without native URL support
The Problem
// URL manipulation has many edge cases:
// Double encoding:
new URL("/path?q=hello%20world", "https://example.com").href
// → "https://example.com/path?q=hello%20world" ✅
// But manually joining:
"/api" + "?q=" + encodeURIComponent("hello world")
// Can lead to double-encoding if input is already encoded
// Trailing slash inconsistencies:
new URL("api/packages", "https://example.com").href
// → "https://example.com/api/packages"
new URL("api/packages", "https://example.com/v1/").href
// → "https://example.com/v1/api/packages"
new URL("api/packages", "https://example.com/v1").href
// → "https://example.com/api/packages" ← v1 is GONE!
// Query string merging is manual:
const url = new URL("https://example.com/api?page=1")
url.searchParams.set("limit", "20")
// Works but verbose for complex cases
ufo
ufo — URL utilities:
Safe URL joining
import { joinURL, withBase, withoutBase } from "ufo"
// joinURL — safely join URL segments:
joinURL("/api", "packages", "react")
// → "/api/packages/react"
joinURL("https://pkgpulse.com", "/api", "/packages")
// → "https://pkgpulse.com/api/packages"
// Handles edge cases:
joinURL("/api/", "/packages/")
// → "/api/packages/" (no double slashes)
joinURL("", "packages", "")
// → "packages"
// withBase / withoutBase:
withBase("/api/packages", "https://pkgpulse.com")
// → "https://pkgpulse.com/api/packages"
withoutBase("https://pkgpulse.com/api/packages", "https://pkgpulse.com")
// → "/api/packages"
Query string utilities
import {
getQuery, withQuery, parseQuery, stringifyQuery,
} from "ufo"
// Parse query from URL:
getQuery("https://pkgpulse.com/api?page=1&limit=20&tags=react,vue")
// → { page: "1", limit: "20", tags: "react,vue" }
// Add/merge query params:
withQuery("/api/packages", { page: 2, limit: 20, sort: "downloads" })
// → "/api/packages?page=2&limit=20&sort=downloads"
// Merge with existing query:
withQuery("/api/packages?page=1", { limit: 20 })
// → "/api/packages?page=1&limit=20"
// Parse query string directly:
parseQuery("page=1&limit=20&tags=react&tags=vue")
// → { page: "1", limit: "20", tags: ["react", "vue"] }
// Stringify query object:
stringifyQuery({ page: 1, limit: 20, tags: ["react", "vue"] })
// → "page=1&limit=20&tags=react&tags=vue"
URL normalization
import {
normalizeURL, withTrailingSlash, withoutTrailingSlash,
withLeadingSlash, withoutLeadingSlash,
cleanDoubleSlashes,
} from "ufo"
// Normalize URLs:
normalizeURL("https://pkgpulse.com/api//packages/../react?")
// → "https://pkgpulse.com/api/react"
// Trailing slash control:
withTrailingSlash("/api/packages")
// → "/api/packages/"
withoutTrailingSlash("/api/packages/")
// → "/api/packages"
// Leading slash:
withLeadingSlash("api/packages")
// → "/api/packages"
withoutLeadingSlash("/api/packages")
// → "api/packages"
// Clean double slashes:
cleanDoubleSlashes("https://pkgpulse.com//api///packages")
// → "https://pkgpulse.com/api/packages"
URL parsing
import { parseURL, parseHost, parsePath } from "ufo"
// Parse URL into components:
parseURL("https://pkgpulse.com:8080/api/packages?page=1#results")
// → {
// protocol: "https:",
// host: "pkgpulse.com:8080",
// pathname: "/api/packages",
// search: "?page=1",
// hash: "#results",
// }
// Encoding-safe:
parseURL("/api/packages/hello%20world")
// → { pathname: "/api/packages/hello%20world" }
// Does NOT double-encode — preserves existing encoding
How H3/Nitro use ufo
// H3 uses ufo for all URL handling:
import { eventHandler, getRequestURL } from "h3"
import { joinURL, getQuery } from "ufo"
export default eventHandler((event) => {
// ufo handles URL parsing internally:
const url = getRequestURL(event)
const query = getQuery(event.path)
// Build API URLs safely:
const apiUrl = joinURL(config.apiBase, "/packages", query.name)
return { url: apiUrl }
})
url-parse
url-parse — universal URL parser:
Basic parsing
import URLParse from "url-parse"
const parsed = new URLParse("https://user:pass@pkgpulse.com:8080/api/packages?page=1#results")
parsed.protocol // "https:"
parsed.username // "user"
parsed.password // "pass"
parsed.hostname // "pkgpulse.com"
parsed.port // "8080"
parsed.pathname // "/api/packages"
parsed.query // "?page=1" (or parsed object with qs)
parsed.hash // "#results"
parsed.origin // "https://pkgpulse.com:8080"
parsed.href // Full URL string
Query string parsing
import URLParse from "url-parse"
import qs from "querystringify"
// With built-in query parser:
const parsed = new URLParse("https://pkgpulse.com/api?page=1&limit=20", true)
parsed.query // { page: "1", limit: "20" }
// Modify query:
parsed.set("query", { page: "2", limit: "20", sort: "name" })
parsed.href // "https://pkgpulse.com/api?page=2&limit=20&sort=name"
URL mutation
import URLParse from "url-parse"
const url = new URLParse("https://pkgpulse.com/api")
// Modify components:
url.set("pathname", "/api/v2/packages")
url.set("query", { page: 1 })
url.set("hash", "results")
url.href // "https://pkgpulse.com/api/v2/packages?page=1#results"
// Relative URL resolution:
const base = new URLParse("https://pkgpulse.com/api/v1/")
const resolved = new URLParse("../v2/packages", base)
resolved.href // "https://pkgpulse.com/api/v2/packages"
Browser + Node.js compatibility
// url-parse works identically in both environments:
// Browser (no native URL in old browsers):
import URLParse from "url-parse"
const url = new URLParse(window.location.href)
// Node.js:
import URLParse from "url-parse"
const url = new URLParse("https://pkgpulse.com")
// Same API, same behavior everywhere
whatwg-url (and built-in URL)
whatwg-url — WHATWG URL Standard:
Built-in URL class
// Node.js URL class is based on the WHATWG spec:
const url = new URL("https://pkgpulse.com:8080/api/packages?page=1#results")
url.protocol // "https:"
url.hostname // "pkgpulse.com"
url.port // "8080"
url.pathname // "/api/packages"
url.search // "?page=1"
url.hash // "#results"
url.origin // "https://pkgpulse.com:8080"
// URLSearchParams:
url.searchParams.get("page") // "1"
url.searchParams.set("limit", "20")
url.searchParams.append("tags", "react")
url.searchParams.append("tags", "vue")
url.searchParams.toString() // "page=1&limit=20&tags=react&tags=vue"
Relative URL resolution
// Resolve relative URLs:
new URL("/api/packages", "https://pkgpulse.com").href
// → "https://pkgpulse.com/api/packages"
new URL("../packages", "https://pkgpulse.com/api/v1/users").href
// → "https://pkgpulse.com/api/packages"
new URL("?page=2", "https://pkgpulse.com/api?page=1").href
// → "https://pkgpulse.com/api?page=2"
URL validation
// Check if a string is a valid URL:
function isValidURL(str: string): boolean {
try {
new URL(str)
return true
} catch {
return false
}
}
isValidURL("https://pkgpulse.com") // true
isValidURL("not-a-url") // false
isValidURL("//pkgpulse.com") // false (no protocol)
whatwg-url package (polyfill)
// The whatwg-url package is a pure JS implementation:
import { URL, URLSearchParams } from "whatwg-url"
// Same API as built-in URL
// Used by jsdom and other tools that need URL in non-browser environments
// In 2026: rarely needed — built-in URL is available everywhere
Feature Comparison
| Feature | ufo | url-parse | whatwg-url / URL |
|---|---|---|---|
| URL parsing | ✅ | ✅ | ✅ |
| URL joining | ✅ (joinURL) | ❌ | ❌ |
| Query utilities | ✅ (rich) | ✅ (basic) | ✅ (URLSearchParams) |
| Normalization | ✅ | ❌ | Partial |
| Trailing slash control | ✅ | ❌ | ❌ |
| Encoding-safe | ✅ | ✅ | ✅ |
| URL mutation | ❌ (functional) | ✅ | ✅ |
| URL validation | ❌ | ❌ | ✅ (try/catch) |
| Browser support | ✅ | ✅ | Built-in |
| TypeScript | ✅ | ✅ | ✅ |
| Used by | Nuxt, H3, Nitro | Legacy apps | Node.js core |
| Weekly downloads | ~10M | ~15M | Built-in |
When to Use Each
Use ufo if:
- Need safe URL joining without double slashes
- Want rich query string utilities (parse, merge, stringify)
- Need URL normalization (trailing slashes, double slashes)
- Building with Nuxt, H3, or the UnJS ecosystem
Use the built-in URL class if:
- Standard URL parsing and validation
- Working with URLSearchParams
- Need WHATWG-compliant behavior
- Want zero dependencies
Use url-parse if:
- Need URL parsing in very old browsers without native URL
- Want a mutable URL object with
.set()API - Maintaining legacy code that already uses url-parse
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on ufo v1.x, url-parse v1.x, and the built-in URL class (Node.js 22).