# c12 resolves a config from nine places, in a fixed order

> c12 is a configuration loader for JavaScript and TypeScript that finds config in files, in .config directories, in rc files, in package.json and in environment variables, then merges them with a defined precedence. It suits a Node project that has outgrown one config file, and its v4 release is still a release candidate.

**unjs/c12** — ⚙️ Smart Configuration Loader

- Repository: https://github.com/unjs/c12
- Stars: 904 · Forks: 62
- Language: TypeScript
- License: MIT
- Published: 2026-10-20 · Updated: 2026-10-20 · Language: en
- Canonical page: https://hysenlabs.com/projects/unjs-c12

## Nine sources, and the order they are merged in

c12, pronounced as /siːtwelv/, is a smart configuration loader, and its central idea is that a project's configuration is rarely in one place. A developer overrides something on the command line, the app has a config file, the user's home directory has an rc file, the package has a name field, and the environment has secrets.

The README lists the merge order, and it is done with unjs/defu. Config overrides passed by options come first, then the config file in the working directory, then the rc file in the working directory, then the rc file in the workspace directory, then the rc file in the user's config directory, either $XDG_CONFIG_HOME or ~/.config, then the legacy rc file in the user's home directory, then config from package.json, then default config passed by options, and finally extended config layers.

That ordering is the real API of the library, and it is worth reading as a policy statement. Explicit options win over files, files in the working directory win over files in the home directory, and the layers come last because an extended config is a base that everything else overrides.

The order also explains an asymmetry that surprises people. The config file in the current directory is read, but the rc file in the home directory is only read when globalRc is enabled, and that option in turn is only enabled when rcFile is provided. Global rc loading is off by default and has to be switched on in two steps.

## Config comes from many file formats, and the loader picks the right one

The format list is what makes c12 a loader rather than a parser. There is a loader for .js, .ts, .mjs, .cjs, .mts and .cts config files, with a customizable import and a fallback to unjs/jiti, which is what lets a TypeScript config file be loaded in a plain Node process. There is a loader for .json using JSON.parse, and .jsonc, .json5, .yaml, .yml and .toml through unjs/confbox.

On top of the file formats there are two directory conventions. A .config/ directory is supported, following the config dir proposal, and .rc config support comes from unjs/rc9.

The .env handling is a separate layer again, and it is disabled by default. When enabled it supports variable interpolation and optional _FILE references resolution, and it can load multiple files that extend each other in left-to-right order when an array of fileNames is passed.

So a project that wants its config in TypeScript for typing, its secrets in .env, and its overrides in the environment can have all three through one call, which is the case c12 exists for. A project with one JSON file and three environment variables does not need it, and the dependency is more than the problem.

## The one line that has to be right

The usage section is short, and one note in it deserves more attention than the rest.

The import is either static or dynamic:

```js
// ESM import
import { loadConfig, watchConfig } from "c12";

// or using dynamic import
const { loadConfig, watchConfig } = await import("c12");
```

The load call returns more than the config object, and the two documented shapes are worth comparing:

```js
// Get loaded config
const { config } = await loadConfig({});

// Get resolved config and extended layers
const { config, configFile, layers } = await loadConfig({});
```

The layers array is the extended configurations that were pulled in, and configFile names the file that was actually used. Both matter when something loads but not from where you expected.

The note is the warning that the merged config may share nested object references with layers[].config, so mutating config can also mutate a layer and vice versa. If you need to mutate the result, clone it first with a deep clone strategy that fits your config, for example klona or structuredClone:

```js
import { klona } from "klona";

const { config: _config, layers } = await loadConfig({});
const config = klona(_config);
```

This is the kind of aliasing bug that costs an afternoon. The merge is a structural merge over live objects, not a copy, and the library is telling you so rather than letting you find out.

## Env files with interpolation and file-mounted secrets

The .env support has more in it than variable loading, and the two options that matter are interpolate and expandFileReferences.

Interpolation is on by default. Variables inside .env files are interpolated with $VAR or ${VAR} syntax, and a default can be given inside braces with ${VAR:-default}, which is used when the variable is unset or empty, or ${VAR-default}, which is used only when it is unset. Defaults can contain interpolations themselves, and a reference that cannot be resolved is kept as-is rather than becoming an empty string, which is the behaviour you want in a container where a missing variable should be loud rather than silently blank.

The example in the README covers the cases:

```bash
# .env
BASE_DIR="/test"
# "/test/further"
ANOTHER_DIR="${BASE_DIR}/further"
# "/test/fallback" when CACHE_DIR_INPUT is unset or empty
CACHE_DIR="${CACHE_DIR_INPUT:-${BASE_DIR}/fallback}"
# "info" when LOG_LEVEL_INPUT is unset (an empty value is kept as-is)
LOG_LEVEL="${LOG_LEVEL_INPUT-info}"
# kept as the literal "${UNKNOWN}" since it cannot be resolved
UNRESOLVED="${UNKNOWN}"
```

expandFileReferences is off by default. When enabled, environment variables ending in _FILE are resolved by reading the file at the given path and assigning its trimmed content to the base key without the suffix. The documentation gives the reason: this is useful for container secrets in Docker and Kubernetes where sensitive values are mounted as files. A typical entry looks like this:

```bash
# .env
DATABASE_PASSWORD_FILE="/run/secrets/db_password"
```

The parser itself is replaceable. The parse option takes a custom .env file parser with a (src: string) => Record<string, string> signature, exported as the DotenvParseFn type, and by default node:util.parseEnv is used when it is available, falling back to the optional dotenv package.

## Extending a base config from a local or git source

The feature that separates c12 from a file reader is extending configurations from multiple local or git sources, and it comes with its own environment of tools.

The manifest shows the cost of that feature. giget is a peer dependency at >=3.1.0, alongside dotenv, jiti and magicast, and all four are marked optional in peerDependenciesMeta, which is the right way to keep them out of an install that does not use the feature. The runtime dependencies are much smaller: confbox for the structured formats, defu for the merge, exsolve for resolving module paths, pathe for cross-platform path handling, pkg-types for reading package metadata and rc9 for rc file resolution.

On the write side there is a separate capability: creating or updating configuration files with magicast, another optional peer dependency. That is a different tool from the loader, and it is the one that lets an installer or a setup command write a config file rather than only read one.

The watcher is the third piece. There is a config watcher with auto-reload and HMR support, and the named export for it is watchConfig, which sits next to loadConfig in the same import statement. Auto-reload and hot module replacement in the same feature means a developer editing a config file sees the change without restarting the process, and the export list suggests that is a first-class use case rather than an afterthought.

For an application that reads configuration once at startup, none of this matters. For a development server that a team keeps running while they work, it is the reason to pick this library.

## A v4 that is still a release candidate

The maintenance picture has an unusual shape, and it is the first thing to weigh.

The published releases are v4.0.0-rc.2 on 2026-09-22, v4.0.0-rc.1 on 2026-09-03 and v4.0.0-beta.5 on 2026-05-06. The version in the package manifest reads 4.0.0-rc.2, so the manifest and the newest tag agree, and the last push to the repository was on 2026-10-18. The repository is not archived.

So the current line is a 4.0 release candidate that has been in candidate form for over five months, and the README opens with a note pointing at a migration section for upgrading from v3 to v4. Both facts together mean the v3 line and the v4 line have meaningfully different behaviour, and a team choosing between them is choosing between a stable API and a newer one.

The tooling around the package is current. The build uses obuild, the test run is vitest with coverage and a separate tsc --noEmit type check, linting is oxlint with oxfmt for formatting, and the release script runs the tests, the build and changelogen before publishing. The package is ESM only, with type module, sideEffects false, and exports for the main entry and an ./update entry point.

The licence is MIT, with the LICENSE file at the root. The dependency surface is small and the optional pieces are correctly marked as optional, which is a well-run package. The only outstanding question is the release candidate status, and the answer to that is in the migration section the README points at.

## Conclusion

c12 suits a Node or TypeScript project whose configuration has spread across more than one place, and which wants one documented precedence order plus the ability to extend a base config from a git source. It is the wrong tool for a project with a single config file, where the dependency is more than the problem, and it is the wrong tool right now for anything that cannot tolerate a pre-1.0 package, since the published releases are v4.0.0 release candidates. Before you adopt it, read the migration section for v3 to v4 that the README points at, decide whether the extended-layers feature is the reason to use it, and remember that merged config may share nested object references with the layers it came from, so clone the result before mutating it.

## FAQ

### How do I install c12 and load a configuration?

Install the package with npx nypm install c12, then import loadConfig from c12 and call it. The call is asynchronous and returns the merged config, along with the configFile that was used and the layers of extended configuration.

### In what order does c12 merge configuration sources?

Using unjs/defu, the order is config overrides passed by options, the config file in the working directory, the rc file in the working directory, the rc file in the workspace directory, the rc file in the user's config directory, the legacy rc file in the home directory, config from package.json, default config passed by options, and finally extended config layers.

### How do I load secrets from files in c12?

Enable the expandFileReferences option on dotenv. Environment variables ending in _FILE are then resolved by reading the file at the given path and assigning its trimmed content to the base key without the suffix, which suits container secrets mounted as files.

### Is c12 ready for production use?

The published releases are v4.0.0 release candidates, v4.0.0-rc.2 on 2026-09-22 and v4.0.0-rc.1 on 2026-09-03, and the README points to a migration section for moving from v3 to v4. The package itself is MIT licensed and its optional peer dependencies are marked optional.

## Sources

- [Issues](https://github.com/unjs/c12/issues)
- [License: MIT](https://github.com/unjs/c12/blob/main/LICENSE)
- [README](https://github.com/unjs/c12/blob/main/README.md)
- [Releases](https://github.com/unjs/c12/releases)
- [unjs/c12 on GitHub](https://github.com/unjs/c12)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/unjs-c12
