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
How ufo Handles Encoding Edge Cases
URL encoding is where most homegrown URL utilities fail silently. The fundamental problem is distinguishing between a URL that contains a literal percent sign (%) and one that already contains an encoded character (%20 for a space). If you naively call encodeURIComponent on a path segment that's already encoded, you double-encode it: %20 becomes %2520. ufo's encoding-aware functions avoid this by checking whether a percent sign is already part of a valid percent-encoded sequence before applying encoding.
joinURL is the clearest example of where this matters. When you join "/api" with "/packages/hello%20world", ufo preserves the existing encoding rather than re-encoding the %. The built-in URL constructor also handles this correctly when given a proper base URL, but ufo's functional API handles it in contexts where you're constructing path segments programmatically rather than full absolute URLs. withQuery is similarly careful: when merging query parameters, it encodes new values but preserves already-encoded values, which prevents the double-encoding bugs that frequently appear when building query strings from user input that may or may not already be encoded.
The parseURL function returns a plain object (not a URL instance) with string properties, which makes it serializable and works cleanly across serialize/deserialize boundaries like Nuxt's server/client data transfer. This is a deliberate design choice: ufo favors plain data objects over class instances throughout its API. The trade-off is that you don't get the automatic re-serialization that new URL() provides when you mutate properties — ufo's API is purely functional, operating on strings and plain objects rather than mutable URL instances.
When the Built-in URL Class Is Enough
The built-in URL class (available in Node.js 10+ and all modern browsers) covers the vast majority of URL parsing needs without any npm dependency. WHATWG URL parsing is stricter than older parsers: new URL("/path") throws because there's no base, while url-parse would return a partial result. This strictness is actually desirable — it forces you to handle relative URLs explicitly rather than getting silently wrong results.
URLSearchParams has improved significantly in recent Node.js versions. It handles arrays via repeated keys (tags=react&tags=vue), supports iterators (for (const [key, value] of params)), and integrates directly with the URL class via url.searchParams. For the common case of reading query parameters from a request URL or building query strings for API calls, URLSearchParams is sufficient and carries zero bundle weight. The main gap compared to ufo is the absence of utilities like withQuery (merging params into an existing URL string) and joinURL (safe path segment joining with slash normalization).
url-parse's ~15M weekly downloads are largely a historical artifact. It became popular when the built-in URL class wasn't available in all target environments (pre-Node.js 10, IE11, early React Native). In 2026, the only genuine reason to use url-parse over the built-in URL is maintaining legacy code that relies on its mutable .set() API pattern, or rare environments where the built-in URL truly isn't available. url-parse received a critical security fix in 2022 for a URL hostname spoofing vulnerability (@ character mishandling), so projects still using it should ensure they're on at least v1.5.10.
ufo in the Nuxt and Nitro Ecosystem
ufo's ~10M weekly downloads are driven primarily by Nuxt 3 and Nitro, which use it as the foundational URL utility throughout the framework. When Nitro resolves route patterns, normalizes API base URLs, or joins asset paths, ufo's functions are doing the work. This means any Nuxt 3 or Nitro project already has ufo in its dependency tree, making it zero-cost to use directly in application code.
H3 (the HTTP framework underlying Nitro) uses ufo's getQuery to parse request query strings into plain objects and joinURL to build redirect and proxy target URLs. If you're writing H3 event handlers, importing getQuery and joinURL from ufo directly (rather than through H3's re-exports) gives access to the full ufo utility surface: withoutTrailingSlash, isRelativeURL, parseURL, stringifyParsedURL, and about 30 other functions. For non-Nuxt projects on the UnJS stack (H3 standalone, unstorage, ofetch), using ufo for URL work keeps the dependency surface consistent and avoids mixing URL libraries with different encoding semantics.
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).
Compare URL utilities and developer tooling on PkgPulse →
The withQuery function in ufo handles a URL construction pattern that is surprisingly difficult to get right with the built-in URL class alone. new URL(url).searchParams.set('key', 'value') only works for absolute URLs — if you have a relative path like /api/packages, you must provide a fake base URL, manipulate the search params, and then strip the fake base from the result. ufo's withQuery('/api/packages', { page: 2, tags: ['react', 'vue'] }) handles relative paths correctly and also handles array values by repeating the key (tags=react&tags=vue), which is the standard query string encoding for arrays. The built-in URLSearchParams.append() can achieve the same array encoding but requires more verbose code and does not handle the path+query string construction in one step. For applications that frequently build query strings for API calls, withQuery replaces a common source of ad-hoc string concatenation bugs.
A frequently overlooked ufo function is isRelativeURL(), which correctly distinguishes protocol-relative URLs (//cdn.example.com/asset.js — starts with //) from root-relative paths (/api/packages — starts with /) from full absolute URLs. The built-in URL constructor throws on protocol-relative URLs without a base, making it difficult to detect them before construction. ufo's isRelativeURL returns true for both protocol-relative and path-relative URLs, while hasProtocol() distinguishes protocol-relative from full absolute. This matters in content security policy enforcement, where you need to know whether a URL references the same origin or an external resource before including it in a CSP header. For teams building content platforms or link validators where user-provided URLs must be classified before processing, these utility functions prevent the edge cases that cause XSS vulnerabilities in homegrown URL classification code.
See also: pm2 vs node:cluster vs tsx watch and h3 vs polka vs koa 2026, better-sqlite3 vs libsql vs sql.js.