unconfig and the problem of a project root full of config files
A universal solution for loading configurations.
At a glance
- What is it?
- antfu-collective/unconfig is a small TypeScript library for tool authors who need to read configuration from wherever their users happen to put it. It gives you a sources array, a rewrite hook and a merge switch, and it explicitly does not solve the fragmentation it describes.
- Who is it for?
- Adopt unconfig when you are building a tool whose users will configure it in more than one plausible place, since the sources array and the rewrite hook turn that ambiguity into a few lines instead of a loader you maintain yourself. Do not adopt it for a project with a single known config format, where a direct import or a JSON read is smaller and easier to reason about.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 13 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 25, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The fragmentation the project set out to describe
The README opens with a complaint rather than a feature list, and the complaint is the design brief. Configuration is hard, especially when you want to build an ecosystem of your tools.
Then it walks through the ways a tool author ends up with a config problem. You want your tools to be general and easy to use, so you allow your config to be defined in a custom field of package.json. You want your tools to be easy to integrate, so you allow configs to be defined in other tools' configurations, like vite.config.js or webpack.config.js. You want configs to be agnostic and probably loadable by an IDE, so you create new config files like .myconfigrc. You want them flexible and dynamic, so you make your config file a JavaScript file, like my.config.js. And then users want ESM and TypeScript, so the config has to accept .ts or .mjs as well.
The consequence is the long list in the README: .npmrc, rollup.config.js, .eslintrc, tsconfig.json, jest.config.js, postcss.config.js, nuxt.config.js, vite.config.cjs, windi.config.ts, and each of them in a different syntax, JSON, CJS, ESM, TypeScript, INI, TOML.
And the honest line at the end of the Why section: unconfig can't solve this fragmentation entirely, but it's trying to make loading them easier for tool authors. That sentence is the product's scope. It is a loading library, not a schema system and not a way of making everyone agree on one file format.
That is the correct ambition, by the way. Standardising config formats is not something a library can do to anyone's existing project. Making it possible to read five candidate locations without writing five parsers is.
The sources array is the entire public surface
There is one function, loadConfig, and one option that really matters, sources. Each entry in that array describes a place a config might live and how to reshape what is found there.
The README's own example, loading config for my.config, is the best documentation of the model:
import { loadConfig } from 'unconfig'
const { config, sources } = await loadConfig({
sources: [
// load from `my.config.xx`
{
files: 'my.config',
// default extensions
extensions: ['ts', 'mts', 'cts', 'js', 'mjs', 'cjs', 'json', ''],
},
// load `my` field in `package.json` if no above config files found
{
files: 'package.json',
extensions: [],
rewrite(config) {
return config?.my
},
},
// load inline config from `vite.config`
{
files: 'vite.config',
async rewrite(config) {
const resolved = await (typeof config === 'function' ? config() : config)
return resolved?.my
},
},
// ...
],
// if false, the only the first matched will be loaded
// if true, all matched will be loaded and deep merged
merge: false,
})Three fields carry the weight. files names the base filename to look for. extensions lists the suffixes to probe, and an empty extensions array means the file is loaded as-is with no suffix probing. rewrite is a function that receives whatever was loaded and returns the part you actually want, which is how a shared file like package.json or vite.config gets reduced to your own field.
The return value is the loaded config plus sources, which tells you where it came from. That matters for a tool, because a tool that can say where it read the config from can explain a surprising value to the user instead of leaving them guessing.
rewrite is the part that makes shared files usable
Focus on the third source in the example, because it is where the design earns its keep. A vite.config file is not your config file. It belongs to another tool, it may be a function that exports a config object, and it usually has fields you have no interest in. Loading it wholesale and merging it into your own settings would be wrong.
The rewrite hook is how you fix that. In the package.json source, rewrite simply returns config?.my, extracting your custom field from the parsed JSON. In the vite.config source, the hook is async and first resolves the loaded value, calling it if it is a function, and then pulls the my field out of the result.
The typeof check is the detail that makes this practical. A config file that exports a function is a common pattern because it lets the file compute its value, and a loader that only handles object exports would break on exactly the file a user is most likely to have. The async rewrite covers the other case, where resolving the config itself is asynchronous.
So the general pattern is: unconfig handles finding files, parsing them and dealing with module formats, and rewrite handles the semantic difference between your config and someone else's. That division is clean, and it is why the same library can read a dedicated file, a field inside package.json, and a nested field of a third-party tool's config, without special cases for each.
The limitation is that rewrite is on the result, not on the file. Anything a config file does at load time, side effects included, has already happened by the time your hook sees it.
Extension probing and the merge switch
The default extension list in the example is ['ts', 'mts', 'cts', 'js', 'mjs', 'cjs', 'json', ''], and the README states that unconfig supports loading ts, mjs, js and json by default. The list also covers the module and CommonJS variants of the TypeScript and JavaScript entries, and ends with an empty string, which leaves matching of an extensionless file to the loader rather than to a named suffix.
This is where the library draws its line, and it is worth being explicit about the line because the fragmentation paragraph in the README mentions INI and TOML among the syntaxes in the wild. Those are not in the default extension list. A library that loads .ts config files cannot load .npmrc without a different parser, so the formats in the fragmentation complaint and the formats in the supported list are not the same set. If your users keep an INI file, you are writing that part yourself.
The merge switch is the other decision. With merge false, the comment says the only the first matched will be loaded, so the first source in the array that matches wins and the rest are ignored. With merge true, all matched will be loaded and deep merged, so a package.json field can sit alongside a dedicated file.
The order of your sources array is therefore the precedence order, and that is a decision you make by writing the array in the order you want. The example encodes a preference: a dedicated my.config file first, then the package.json field, then a third-party config, which is the order of specificity rather than the order of convenience.
The repository is a pnpm workspace with a small public package
The published package is unconfig on npm, installed the usual way:
npm i unconfigWhat is in the repository is a monorepo around it. The root package.json is named unconfig-monorepo, is marked as an ES module package with type module, and declares [email protected] as its packageManager. The top level holds a packages/ directory alongside pnpm-workspace.yaml, pnpm-lock.yaml, tsconfig.json, eslint.config.js, tsdown.config.ts, vitest.config.ts and an .npmrc.
The scripts tell you the toolchain: build runs tsdown, lint runs eslint, release runs bumpp -r, test runs vitest and typecheck runs tsc. Every devDependency is pinned to catalog:, which is pnpm's catalog mechanism for sharing a single version of a dependency across the workspace packages rather than repeating the version in each. That is a small sign of a deliberately tidy monorepo.
For a consumer, none of that matters except one thing: the library is a dependency you import, and the tooling that produced it is visible in the tree if you need to check how it is built. The test framework is vitest and the type check is tsc, which fits a TypeScript-first library where the public types are part of the product.
MIT licensing, a 7.x version line, and what is not covered
The licence is MIT, with the file at the root of the repository and the README naming the copyright holder as Anthony Fu for 2021 and present. The listed releases are v7.5.0 on 2026-02-14, v7.4.2 on 2025-12-04 and v7.4.1 on 2025-11-11, and the last push was on 2026-10-12. The repository is not archived.
The version number deserves attention. A library sitting at 7.x has crossed seven major versions, and semver majors mean breaking changes, so a tool that depends on unconfig has to deal with a dependency that changes its interface across majors. The release cadence in that list, several versions across the span from late 2025 to early 2026, suggests active iteration, and a project that is willing to ship breaking changes is not one whose API you should assume is frozen.
On the missing side, the README is short, and a few things a configuration library would normally raise are simply not addressed. There is no documented schema validation step, so a config that is syntactically loadable but semantically wrong reaches your code and fails later. There is no documented caching behaviour, so every invocation of loadConfig re-reads from disk. There is no watch mode, and no documented way to be told when a config file changes. The README also does not document error behaviour when a file exists but cannot be parsed.
None of those absences make it a bad fit, but they are the questions to answer before you depend on it in a tool where config errors are user-facing.
The alternative: write the small loader yourself
The real alternative is not another package, it is twenty lines of your own code, and for a single-config tool that is often the better answer.
Writing it yourself means you know exactly which formats you support, exactly what happens on a parse error, and exactly how your defaults and user values combine, with no dependency and no version drift. If your tool reads one file, in one format, from a fixed path, that is what you should do.
unconfig is worth the dependency in a different situation. The moment your users might have put the config in package.json, or in a dedicated file, or nested inside vite.config, or written it as TypeScript, the work is not twenty lines any more. You need file discovery, extension probing, module format handling, a way to reduce a foreign file to your own field, and a defined precedence between candidate locations. unconfig gives you all of that as a sources array you can read in one screen, and it gives every tool in an ecosystem the same answer about where the config lives, which is the part the README cares about with its mention of building an ecosystem of your tools.
The honest framing is that unconfig replaces a pile of ad hoc loaders scattered across your tool ecosystem with one shared implementation. If you have one tool, it is optional. If you have five, it is the thing that stops them disagreeing.
Editorial conclusion
Adopt unconfig when you are building a tool whose users will configure it in more than one plausible place, since the sources array and the rewrite hook turn that ambiguity into a few lines instead of a loader you maintain yourself. Do not adopt it for a project with a single known config format, where a direct import or a JSON read is smaller and easier to reason about. Verify first which file types your users actually use, because the default extension list covers ts, mjs, js and json, and then read the sources array in the README example before you add a source, since the merge flag changes what a second matching file does.
Frequently asked questions
How do I install unconfig and load a config file?
Install it with npm i unconfig, then import loadConfig and pass a sources array naming the files to probe and the extensions to try. The loaded result comes back as an object with a config property and a sources property showing where the config was found.
Which config file types does unconfig support by default?
The README states that unconfig supports loading ts, mjs, js and json by default, and the example source uses the extension list ['ts', 'mts', 'cts', 'js', 'mjs', 'cjs', 'json', '']. Other syntaxes such as INI and TOML are not in that list.
How do I read my config out of package.json or vite.config with unconfig?
You use the rewrite option on that source. For package.json the example returns config?.my, and for vite.config the example resolves the value first, calling it when it is a function, and then returns the my field of the result. The rewrite hook can be async.
What is the difference between merge false and merge true in unconfig?
With merge false only the first matched source is loaded, so the first entry in the sources array that matches wins. With merge true all matched sources are loaded and deep merged, so values from several candidate files combine.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/antfu-collective-unconfig)Was this useful?