pathe vs node:path vs upath: Cross-Platform Path Utilities in Node.js (2026)
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.posixfor 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
| Feature | pathe | node:path | upath |
|---|---|---|---|
| Always forward slashes | ✅ | ❌ (use .posix) | ✅ |
| Normalize backslashes | ✅ | ❌ (.posix won't) | ✅ |
| ESM native | ✅ | ✅ | ⚠️ (CJS-first) |
| TypeScript | ✅ | ✅ (@types/node) | ✅ |
| Extension helpers | ❌ | extname only | ✅ (changeExt, etc.) |
| Alias resolution | ✅ (utils) | ❌ | ❌ |
| Dependencies | 0 | Built-in | 0 |
| Maintained | ✅ (UnJS) | ✅ (Node.js) | ⚠️ |
| Weekly downloads | ~10M | Built-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
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.