Skip to main content

Guide

pathe vs node:path vs upath (2026)

Compare pathe, node:path, and upath for cross-platform path manipulation in Node.js. Forward slash normalization, Windows compatibility, and ESM support.

·PkgPulse Team·
0

TL;DR

pathe is the UnJS path utility — same API as node:path but always uses forward slashes, works identically on Windows and POSIX, ESM-native. node:path is the built-in Node.js path module — path.posix for forward slashes, path.win32 for backslashes, platform-dependent by default. upath wraps node:path to always return forward slashes — drop-in replacement, normalizes Windows backslashes to forward slashes. In 2026: pathe for cross-platform tools (especially UnJS/Vite ecosystem), node:path for platform-native behavior, upath for simple backslash-to-forward-slash normalization.

Key Takeaways

  • pathe: ~10M weekly downloads — UnJS, always forward slashes, same API as node:path
  • node:path: built-in — platform-native, path.posix for forced forward slashes
  • upath: ~10M weekly downloads — wraps node:path, normalizes to forward slashes
  • The problem: Windows uses \ separators, POSIX uses / — inconsistent paths break tools
  • Forward slashes work everywhere: URLs, glob patterns, import paths, config files
  • pathe and upath solve this by always returning / separators

The Problem

import path from "node:path"

// On macOS/Linux:
path.join("src", "components", "App.tsx")
// → "src/components/App.tsx" ✅

// On Windows:
path.join("src", "components", "App.tsx")
// → "src\\components\\App.tsx" ❌ (backslashes!)

// This breaks:
// - Glob patterns: "src/**/*.tsx" won't match "src\\components\\App.tsx"
// - URL paths: new URL("src\\components\\App.tsx") is wrong
// - Config files: paths in configs should use forward slashes
// - Import specifiers: import "./src\\utils" doesn't work

// Workaround with node:path:
path.posix.join("src", "components", "App.tsx")
// → "src/components/App.tsx" ✅ (but verbose)

pathe

pathe — cross-platform paths:

Drop-in replacement

// Replace:
import path from "node:path"
// With:
import { join, resolve, relative, dirname, basename, extname, normalize } from "pathe"

// Same API, always forward slashes:
join("src", "components", "App.tsx")
// → "src/components/App.tsx" (on ALL platforms)

resolve("src", "utils")
// → "/Users/royce/project/src/utils" (forward slashes)

relative("/Users/royce/project", "/Users/royce/project/src/index.ts")
// → "src/index.ts" (forward slashes)

dirname("/Users/royce/project/src/index.ts")
// → "/Users/royce/project/src"

basename("/Users/royce/project/src/index.ts")
// → "index.ts"

extname("App.tsx")
// → ".tsx"

normalize

import { normalize, toNamespacedPath } from "pathe"

// Normalizes Windows paths:
normalize("src\\components\\App.tsx")
// → "src/components/App.tsx"

normalize("C:\\Users\\royce\\project")
// → "C:/Users/royce/project"

// Handles redundant separators and dots:
normalize("src//components/../utils/./helpers.ts")
// → "src/utils/helpers.ts"

Extra utilities

import { filename, normalizeAliases, resolveAlias } from "pathe/utils"

// filename — basename without extension:
filename("/src/components/App.tsx")
// → "App"

// Alias resolution:
const aliases = { "@": "/Users/royce/project/src", "~": "/Users/royce/project" }
resolveAlias("@/utils/helpers", aliases)
// → "/Users/royce/project/src/utils/helpers"

Why Vite/Nuxt use pathe

In Vite and Nuxt codebases:
  - Config paths must be cross-platform
  - Glob patterns need forward slashes
  - Module IDs use forward slashes
  - Generated import paths must be valid

Using node:path on Windows would break:
  vite.config.ts with resolve.alias paths
  Glob patterns in plugins
  Virtual module IDs
  Source map paths

pathe ensures consistent paths everywhere.

node:path

node:path — built-in path module:

Platform-dependent (default)

import path from "node:path"

// Default — uses platform separators:
path.join("src", "utils")
// macOS/Linux: "src/utils"
// Windows: "src\\utils"

path.resolve("src", "index.ts")
// macOS: "/Users/royce/project/src/index.ts"
// Windows: "C:\\Users\\royce\\project\\src\\index.ts"

path.sep
// macOS/Linux: "/"
// Windows: "\\"

path.posix (forced forward slashes)

import path from "node:path"

// Force POSIX behavior (forward slashes):
path.posix.join("src", "utils")
// → "src/utils" (everywhere)

path.posix.normalize("src//utils/../helpers")
// → "src/helpers"

// But path.posix doesn't normalize Windows paths:
path.posix.normalize("src\\utils\\helpers")
// → "src\\utils\\helpers" (doesn't convert backslashes!)
// You'd need to replace manually: .replace(/\\/g, "/")

path.win32 (forced Windows behavior)

import path from "node:path"

// Force Windows behavior:
path.win32.join("src", "utils")
// → "src\\utils" (everywhere)

path.win32.resolve("C:", "Users", "royce")
// → "C:\\Users\\royce"

When node:path is enough

Use node:path when:
  ✅ Working with actual filesystem paths on the current platform
  ✅ Reading/writing files (OS expects native separators)
  ✅ Process arguments that use native paths
  ✅ You only target one platform (e.g., Linux servers)

Use pathe/upath when:
  ✅ Generating paths for config files, URLs, imports
  ✅ Working with glob patterns
  ✅ Building cross-platform tools
  ✅ Comparing paths across platforms

upath

upath — forward-slash path wrapper:

Drop-in replacement

import upath from "upath"

// Same API as node:path, always forward slashes:
upath.join("src", "components", "App.tsx")
// → "src/components/App.tsx"

upath.resolve("C:\\Users\\royce\\project")
// → "C:/Users/royce/project"

upath.normalize("src\\utils\\..\\components")
// → "src/components"

Extra methods

import upath from "upath"

// toUnix — convert any path to forward slashes:
upath.toUnix("C:\\Users\\royce\\project\\src")
// → "C:/Users/royce/project/src"

// changeExt — change file extension:
upath.changeExt("src/index.ts", ".js")
// → "src/index.js"

// addExt — add extension if missing:
upath.addExt("src/index", ".ts")
// → "src/index.ts"

upath.addExt("src/index.ts", ".ts")
// → "src/index.ts" (already has .ts)

// removeExt — remove extension:
upath.removeExt("src/index.ts", ".ts")
// → "src/index"

// trimExt — remove any extension:
upath.trimExt("src/index.ts")
// → "src/index"

upath vs pathe

upath:
  ✅ Wraps node:path — exact same behavior + forward slashes
  ✅ Extra methods (changeExt, addExt, removeExt, trimExt)
  ✅ Mature — available since 2014
  ❌ CJS-first (ESM wrapper available)
  ❌ Not actively maintained
  ❌ Depends on node:path internals

pathe:
  ✅ ESM-native
  ✅ Zero dependencies
  ✅ Actively maintained (UnJS)
  ✅ Alias resolution utilities
  ❌ No changeExt/addExt/removeExt helpers
  ❌ Slightly different edge cases from node:path

Feature Comparison

Featurepathenode:pathupath
Always forward slashes❌ (use .posix)
Normalize backslashes❌ (.posix won't)
ESM native⚠️ (CJS-first)
TypeScript✅ (@types/node)
Extension helpersextname only✅ (changeExt, etc.)
Alias resolution✅ (utils)
Dependencies0Built-in0
Maintained✅ (UnJS)✅ (Node.js)⚠️
Weekly downloads~10MBuilt-in~10M

When to Use Each

Use pathe if:

  • Building cross-platform tools (CLIs, bundlers, linters)
  • Need consistent forward-slash paths for configs and globs
  • In the UnJS ecosystem (Nuxt, Vite, Nitro)
  • Want modern ESM-native path utility

Use node:path if:

  • Working with actual filesystem operations (read/write)
  • Only targeting one platform
  • Don't need cross-platform path normalization
  • Want zero dependencies (built-in)

Use upath if:

  • Need forward-slash normalization + extension helpers
  • Existing project already using upath
  • Want the closest behavior to node:path with forward slashes

Real-World Adoption in 2026

The npm download numbers tell an interesting story: both pathe and upath pull about 10M weekly downloads, but their user bases are almost completely different.

pathe is used almost exclusively in the UnJS ecosystem and tools that explicitly target cross-platform builds. Check node_modules in a Nuxt 3 project and you'll find pathe referenced in Vite, Nitro, unbuild, and a dozen other UnJS packages. The reason: these tools generate paths for config files, virtual module IDs, and import specifiers — all contexts where Windows backslashes would break things silently. UnJS made pathe the standard for this use case.

upath has its install base in older tooling that needed Windows compatibility before pathe existed. Many webpack plugins, Jest transformers, and CLI tools from the 2015-2020 era use upath. The package isn't actively maintained (last major update in 2019), but it works reliably because it's a simple wrapper around node:path. Packages that depend on it haven't felt pressure to switch.

node:path with path.posix is the approach used in many Node.js core utilities and older stable codebases. It's verbose but explicit. If your codebase already uses path.posix.join(...) everywhere and it works, there's no compelling reason to add pathe.


Migrating from node:path to pathe

Switching from node:path to pathe is a drop-in replacement for most code:

# Install pathe
npm install pathe

# Or with pnpm
pnpm add pathe
// Before:
import path from "node:path"
import { join, resolve, dirname, basename, extname, normalize } from "path"

// After:
import { join, resolve, dirname, basename, extname, normalize } from "pathe"
// No other changes needed — same function signatures

The only cases where pathe behavior differs from node:path:

import path from "node:path"
import { normalize } from "pathe"

// 1. path.sep — pathe always returns "/" (not platform-native):
path.sep         // "/" on macOS, "\\" on Windows
import { sep } from "pathe"
sep              // "/" always (forward slash)

// 2. path.win32 — not available in pathe:
path.win32.join("a", "b")   // "a\\b" anywhere
// No pathe equivalent — pathe only does forward slashes

// 3. Absolute Windows paths — pathe normalizes them:
normalize("C:\\Users\\royce")
// node:path.posix: "C:\\Users\\royce" (unchanged — won't convert)
// pathe: "C:/Users/royce" (converts backslashes)

For most TypeScript projects and build tools, the migration takes 5 minutes: replace the import, run tests, done.


Common Cross-Platform Path Gotchas

Even experienced Node.js developers hit these edge cases when targeting Windows:

import { join, resolve } from "pathe"

// 1. glob patterns must use forward slashes:
// ❌ Breaks on Windows with node:path:
import { glob } from "glob"
glob.sync(path.join("src", "**", "*.ts"))
// → "src\\**\\*.ts" on Windows — globby won't match!

// ✅ Works everywhere with pathe:
glob.sync(join("src", "**", "*.ts"))
// → "src/**/*.ts" — works on all platforms

// 2. import specifiers need forward slashes:
// ❌ Windows node:path:
const specifier = `./${path.relative(from, to)}`
// → "./src\\utils\\helpers" — invalid import

// ✅ pathe:
const specifier = `./${relative(from, to)}`
// → "./src/utils/helpers" — valid everywhere

// 3. JSON/YAML config paths:
// tsconfig.json, vite.config.ts, package.json — all expect forward slashes
// pathe ensures your generated configs work on all platforms

// 4. URL construction:
// ❌ path.join creates backslashes on Windows
new URL(path.join("assets", "img", "logo.png"), "https://example.com")
// → "https://example.com/assets\\img\\logo.png" (broken URL)

// ✅ pathe always works:
new URL(join("assets", "img", "logo.png"), "https://example.com")
// → "https://example.com/assets/img/logo.png"

The practical rule: use node:path when you're working with actual OS file system operations (passing paths to fs.readFile, fs.writeFile, child_process.exec). Use pathe for everything else — generating paths for configs, URLs, imports, or any context where the output needs to be readable across platforms.


Performance and Bundle Size Comparison

Bundle size matters for tools that ship to the browser or edge runtimes. Here is how these options compare when you import the common path functions:

LibraryMin+gzip sizeInstall sizeDependencies
pathe~2.1 KB~15 KB0
upath~3.8 KB~22 KB0
node:path0 KB (built-in)0 KB0

pathe is the lightest third-party option. Since it reimplements the POSIX path logic from scratch rather than wrapping node:path, it can tree-shake cleanly — importing only join and dirname from pathe pulls in roughly 900 bytes minified and gzipped. For edge runtimes like Cloudflare Workers where bundle size has hard limits, this matters.

upath wraps node:path internally, which means in environments where node:path is not available (like some edge runtimes), upath falls back to its own implementation — making bundle size slightly higher.

Community Adoption in 2026

Weekly npm downloads (early 2026 estimates):

PackageWeekly DownloadsGitHub StarsFirst Release
pathe~10M700+2022
upath~10M380+2014
node:pathBuilt-inN/A2009

The headline number — both pathe and upath at ~10M weekly downloads — obscures very different user bases. pathe's downloads are primarily driven by transitive dependencies: Vite, Nuxt, Nitro, unbuild, and the rest of the UnJS ecosystem all depend on pathe. A single Nuxt install chains through a dozen UnJS packages that each list pathe as a dependency.

upath's install base comes from a completely different era of tooling. Build tools, webpack plugins, and scaffolding scripts written between 2015 and 2021 commonly reached for upath because pathe did not exist yet. These packages are stable and do not change often — so upath's download count stays high even though new projects rarely choose it.

In terms of active community engagement, pathe is clearly more alive. The UnJS organization maintains it alongside Nitro, ofetch, h3, and a dozen other ecosystem packages. Issues get responses within days. upath's GitHub shows the last meaningful activity in 2019-2020 — it works, but nobody is adding features or fixing edge cases.

Decision Guide: Which to Use in 2026

The decision comes down to three questions:

1. Are you in the UnJS/Vite/Nuxt ecosystem? If yes, you likely already have pathe installed as a transitive dependency. Use it. The API is identical to node:path, and you get cross-platform path normalization for free.

2. Do you need extension manipulation helpers? upath has changeExt, addExt, removeExt, and trimExt. pathe does not. If you frequently manipulate file extensions (common in asset pipelines and code generators), upath's helpers save you from writing utility functions. Otherwise this is not a factor.

3. Do you only run on Linux servers? If your code never runs on Windows — a Linux-only CI environment and Linux production servers — node:path is sufficient. Forward-slash normalization only matters when the code runs on Windows or when the path output is consumed by something platform-agnostic (globs, URLs, config files). Server-only Node.js code reading and writing files can use node:path without issues.

ScenarioRecommended
Cross-platform tool or librarypathe
UnJS / Nuxt / Vite projectpathe
Need changeExt/addExt helpersupath
Linux-only server codenode:path
Browser or edge runtime bundlepathe
Existing project already on upathKeep upath

Methodology

Download data from npm registry (weekly average, February 2026). Feature comparison based on pathe v1.x, Node.js 22 path module, and upath v2.x.

Testing Cross-Platform Path Code

One aspect of path handling that trips up even experienced Node.js developers is testing path logic across platforms when your CI runs on Linux only. A common mistake is writing tests that pass path strings with forward slashes and concluding the code is cross-platform — but that only validates POSIX behavior. The correct approach is to test the output of your path functions against what they would produce on Windows even when running on Linux. With pathe, this is straightforward because the output is always forward slashes regardless of platform, so tests written on macOS produce identical assertions on Windows. With node:path, you must mock path.sep and use path.win32 explicitly in tests that assert Windows path behavior. Tools like memfs and mock-fs let you stub the filesystem but do not address the path separator issue — that requires either pathe or deliberate use of path.posix/path.win32 throughout your path-manipulation logic.

Compare path utility package health on PkgPulse. Also see cac vs meow vs arg 2026 and cosmiconfig vs lilconfig vs c12 for related cross-platform tooling.

Related: archiver vs adm-zip vs JSZip (2026).

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.