Skip to main content

ufo vs url-parse vs whatwg-url: URL Parsing and Manipulation in Node.js (2026)

·PkgPulse Team

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 URL class (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

Featureufourl-parsewhatwg-url / URL
URL parsing
URL joining✅ (joinURL)
Query utilities✅ (rich)✅ (basic)✅ (URLSearchParams)
NormalizationPartial
Trailing slash control
Encoding-safe
URL mutation❌ (functional)
URL validation✅ (try/catch)
Browser supportBuilt-in
TypeScript
Used byNuxt, H3, NitroLegacy appsNode.js core
Weekly downloads~10M~15MBuilt-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).

Compare URL utilities and developer tooling on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.