env-paths
env-paths answers one question: where should my app store its files on this OS? You call envPaths('MyApp') and get back five directory strings: data, config, cache, log, and temp, each pointing at the platform-correct location. That means Library/Application Support, Preferences, Caches, and Logs on macOS, APPDATA and LOCALAPPDATA subfolders on Windows, and the XDG base directory spec on Linux, including respect for XDG_DATA_HOME, XDG_CONFIG_HOME, XDG_CACHE_HOME, and XDG_STATE_HOME overrides. It only builds path strings; it never touches the filesystem, and by default it appends a -nodejs suffix to your app name to avoid colliding with a native app of the same name.
The correct 30 lines of platform knowledge you should not rewrite yourself, which is why it sees roughly 80M weekly downloads from 452 stars. Use it directly for paths, or use conf when you realize paths were only step one of your config problem.
Use it if
- You ship a CLI or desktop tool and want config and cache files in the places each OS expects instead of dumping dotfiles into the user's home directory
- You want Linux XDG environment variable overrides handled correctly for free, which most hand-rolled path logic gets wrong
- You need the five standard locations (data, config, cache, log, temp) from one tiny dependency with bundled TypeScript types
- You are already avoiding heavier config stores and just want the paths, keeping file formats and IO under your own control
- You actually want a config store, not paths: the conf package handles the file location, atomic writes, schema validation, and migrations for you, and this library alone still leaves you writing all the IO
- You are on CommonJS and cannot switch: the package has been pure ESM since v3, so require('env-paths') throws, and v4 also raised the Node floor to >=20
- The default -nodejs suffix is a problem for you: paths come out as MyApp-nodejs, disabling it is documented as a last resort, and changing your mind later means migrating users' existing data directories
- You need more than these five locations, such as system-wide directories, a runtime dir, or per-user vs machine scopes; this library has no opinion on any of that
Setup reality
npm install env-paths and one function call; there is genuinely almost nothing to set up. The catches: it is pure ESM, so CommonJS projects need a dynamic import or have to stay on the ancient v2. Since v4 the name you pass is validated with is-safe-filename and throws on path separators or reserved characters, so version 4 can reject input that version 3 silently accepted. It returns strings only, so you must create directories yourself with fs.mkdir recursive before writing, and the docs remind you of that because everyone forgets it once. On Linux the temp path nests under your username in /tmp, which surprises people reading logs.
Patterns
Get the five standard directoriesget-standard-paths
import envPaths from 'env-paths';
const paths = envPaths('MyApp');
console.log(paths.data); // ~/.local/share/MyApp-nodejs on Linux
console.log(paths.config); // ~/.config/MyApp-nodejs on Linux
console.log(paths.cache);
console.log(paths.log);
console.log(paths.temp);Note the -nodejs suffix in every path: it is appended by default to avoid clashing with a native app of the same name.
Create the directories before writingcreate-directories
import fs from 'node:fs/promises';
import envPaths from 'env-paths';
const paths = envPaths('MyApp');
await fs.mkdir(paths.config, { recursive: true });
await fs.mkdir(paths.cache, { recursive: true });env-paths only generates strings and never creates anything. Writing a file into paths.config without mkdir first fails with ENOENT on a fresh machine.
Read and write a JSON config filejson-config-file
import fs from 'node:fs/promises';
import path from 'node:path';
import envPaths from 'env-paths';
const configDir = envPaths('mytool').config;
const configFile = path.join(configDir, 'config.json');
async function loadConfig() {
try {
return JSON.parse(await fs.readFile(configFile, 'utf8'));
} catch {
return {};
}
}
async function saveConfig(config) {
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(configFile, JSON.stringify(config, null, 2));
}If you find yourself adding defaults, validation, and atomic writes here, that is the point where the conf package earns its install.
Cache downloaded artifactscache-downloads
import fs from 'node:fs/promises';
import path from 'node:path';
import envPaths from 'env-paths';
const cacheDir = envPaths('mytool').cache;
async function cachedFetch(name, download) {
const file = path.join(cacheDir, name);
try {
return await fs.readFile(file);
} catch {
const data = await download();
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(file, data);
return data;
}
}The cache directory is for regenerable data the OS or user may wipe at any time; never keep the only copy of anything there.
Put log files where the OS expects themlog-file-location
import fs from 'node:fs';
import path from 'node:path';
import envPaths from 'env-paths';
const logDir = envPaths('mytool').log;
fs.mkdirSync(logDir, { recursive: true });
const logStream = fs.createWriteStream(
path.join(logDir, 'mytool.log'),
{ flags: 'a' }
);
logStream.write('started\n');On Linux this maps to XDG_STATE_HOME (default ~/.local/state), not ~/.cache, following the XDG spec's placement for logs.
Drop or change the -nodejs suffixdisable-name-suffix
import envPaths from 'env-paths';
const plain = envPaths('MyApp', { suffix: '' });
// ~/.config/MyApp instead of ~/.config/MyApp-nodejs
const branded = envPaths('MyApp', { suffix: 'cli' });
// ~/.config/MyApp-cliThe README warns against disabling the suffix unless you must, because a native app named MyApp would collide with your directories. Decide before you ship; changing later strands existing user data.
Use a namespaced temp directorytemp-workspace
import fs from 'node:fs/promises';
import envPaths from 'env-paths';
const tempDir = envPaths('mytool').temp;
await fs.mkdir(tempDir, { recursive: true });
// ... write scratch files ...
await fs.rm(tempDir, { recursive: true, force: true });On Linux the path includes the username (/tmp/USERNAME/mytool-nodejs), and on macOS it lands in the per-user folder under /var/folders, so do not hardcode /tmp expectations in tests.
Redirect paths in tests via XDG variablesrespect-xdg-overrides
import test from 'node:test';
import assert from 'node:assert';
import envPaths from 'env-paths';
test('config path honors XDG_CONFIG_HOME', () => {
process.env.XDG_CONFIG_HOME = '/tmp/test-config';
const paths = envPaths('mytool');
assert.ok(paths.config.startsWith('/tmp/test-config'));
});On Linux the XDG variables are read from process.env when envPaths() is called, so setting them in a test before the call works. This only applies to Linux; macOS and Windows paths ignore XDG variables.
Load it from a CommonJS projectuse-from-commonjs
// index.cjs
async function main() {
const { default: envPaths } = await import('env-paths');
const paths = envPaths('mytool');
console.log(paths.config);
}
main();require('env-paths') throws ERR_REQUIRE_ESM for v3 and v4. Dynamic import works from CJS; the old CJS v2 lacks the log path fix and current name validation.
Handle the v4 safe-name checkvalidate-app-name
import envPaths from 'env-paths';
// Fine
const ok = envPaths('my-tool');
// Throws since v4: path separators are not safe filenames
try {
envPaths('my/tool');
} catch (error) {
console.error('invalid app name:', error.message);
}v4 runs the name through is-safe-filename and throws on separators and reserved characters, where v3 would happily build a nested or broken path. Validate user-provided names before passing them in.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| conf | npm | You want the whole config problem solved (correct location plus load, save, schema, migrations) rather than just path strings. |
| xdg-basedir | npm | You only target Linux and want raw XDG base directories without app-name joining or macOS and Windows logic. |
| platformdirs | PyPI | You need the same OS-correct directories idea in a Python project. |