cosmiconfig vs lilconfig vs c12: Config File Loading in Node.js (2026)
TL;DR
cosmiconfig is the standard config-file loader — used by ESLint, Prettier, Babel, Jest, and hundreds of other tools. It searches for config in package.json, .toolrc, .toolrc.json, .toolrc.js, and tool.config.js. lilconfig is cosmiconfig without async loaders — smaller and faster for synchronous config loading. c12 is from the UnJS ecosystem (Nuxt, Nitro) — supports TypeScript configs natively, environment variable merging, layer configs, and a more modern API. In 2026: cosmiconfig for libraries that need standard config discovery, c12 for apps that need TypeScript configs and environment-aware configuration.
Key Takeaways
- cosmiconfig: ~85M weekly downloads — the industry standard, used by ESLint, Prettier, Babel, Jest
- lilconfig: ~80M weekly downloads — synchronous cosmiconfig alternative, smaller, faster
- c12: ~5M weekly downloads — TypeScript-native, env merging, layers, UnJS ecosystem
- cosmiconfig searches 8+ file locations automatically — developers always find their config file
- c12 supports
import type { Config }with full TypeScript autocomplete - All three allow tools to support multiple config file formats without code duplication
Why Config Loaders Exist
Without a config loader, your tool handles:
"Does the config go in package.json?"
"Do we support .myrc files?"
"What about myrc.json vs myrc.yaml?"
"What if they use myrc.js for computed config?"
"What about TypeScript myrc.ts configs?"
With cosmiconfig, one call handles ALL of this:
cosmiconfig("mytool").search()
Checks automatically:
1. package.json → "mytool" key
2. .mytoolrc
3. .mytoolrc.json
4. .mytoolrc.yaml / .mytoolrc.yml
5. .mytoolrc.js / .mytoolrc.cjs / .mytoolrc.mjs
6. mytool.config.js / mytool.config.cjs / mytool.config.mjs
cosmiconfig
cosmiconfig — the standard config file finder:
Basic usage (in your tool)
import { cosmiconfig } from "cosmiconfig"
// Search for "mytool" config starting from current directory:
const explorer = cosmiconfig("mytool")
// Search (walks up directory tree):
const result = await explorer.search()
// result === null if no config found
// result === { config: { ... }, filepath: "/path/to/.mytoolrc", isEmpty: false }
if (!result) {
console.log("No config found — using defaults")
} else {
console.log("Config loaded from:", result.filepath)
console.log("Config:", result.config)
}
// Load a specific file:
const explicit = await explorer.load("/path/to/custom.config.js")
What files cosmiconfig searches
// cosmiconfig("pkgpulse") searches for:
// package.json → "pkgpulse" key
// .pkgpulserc
// .pkgpulserc.json
// .pkgpulserc.yaml
// .pkgpulserc.yml
// .pkgpulserc.js
// .pkgpulserc.cjs
// .pkgpulserc.mjs
// pkgpulse.config.js
// pkgpulse.config.cjs
// pkgpulse.config.mjs
//
// Walks up from process.cwd() to root, checking each directory
// Stops at first config found (or at root)
Custom loaders
import { cosmiconfig } from "cosmiconfig"
import { parse as parseToml } from "smol-toml"
// Add TOML support:
const explorer = cosmiconfig("mytool", {
loaders: {
".toml": (filepath, content) => {
return parseToml(content)
},
},
searchPlaces: [
"package.json",
".mytoolrc",
".mytoolrc.json",
".mytoolrc.toml", // Custom TOML support
".mytoolrc.yaml",
"mytool.config.js",
"mytool.config.ts", // TypeScript support (with tsx loader)
],
})
// TypeScript config loader (using tsx):
import { createJiti } from "jiti"
const jiti = createJiti(import.meta.url)
const explorer = cosmiconfig("mytool", {
loaders: {
".ts": async (filepath) => {
const mod = await jiti.import(filepath)
return (mod as any).default ?? mod
},
},
searchPlaces: [
"package.json",
".mytoolrc",
".mytoolrc.json",
"mytool.config.ts",
"mytool.config.js",
],
})
Caching
import { cosmiconfig } from "cosmiconfig"
// cosmiconfig caches results by default:
const explorer = cosmiconfig("mytool")
// Clear the cache (e.g., in watch mode after file changes):
explorer.clearSearchCache()
explorer.clearLoadCache()
explorer.clearCaches() // Both
// Disable cache:
const fresh = cosmiconfig("mytool", {
cache: false,
})
Real-world: building a CLI tool
import { cosmiconfig } from "cosmiconfig"
import { z } from "zod"
// Define config schema:
const ConfigSchema = z.object({
outputDir: z.string().default("dist"),
format: z.enum(["esm", "cjs", "both"]).default("both"),
watch: z.boolean().default(false),
entries: z.array(z.string()).default(["src/index.ts"]),
})
type Config = z.infer<typeof ConfigSchema>
async function loadConfig(cwd = process.cwd()): Promise<Config> {
const explorer = cosmiconfig("pkgbuild")
const result = await explorer.search(cwd)
if (!result || result.isEmpty) {
return ConfigSchema.parse({}) // All defaults
}
// Validate with zod:
return ConfigSchema.parse(result.config)
}
// User's project can have any of:
// package.json: { "pkgbuild": { "format": "esm" } }
// .pkgbuildrc: { "format": "esm" }
// pkgbuild.config.js: export default { format: "esm" }
lilconfig
lilconfig — sync-only, smaller cosmiconfig:
Why lilconfig exists
// lilconfig has the same API as cosmiconfig but:
// - No async YAML support by default (smaller bundle)
// - Synchronous operations are the focus
// - ~3x smaller than cosmiconfig
// - Drop-in replacement for CJS sync use cases
// Most tools that don't need YAML loaders use lilconfig:
import { lilconfig, lilconfigSync } from "lilconfig"
// Synchronous search:
const result = lilconfigSync("mytool").search()
const config = result?.config
// Asynchronous:
const asyncResult = await lilconfig("mytool").search()
Performance difference
cosmiconfig vs lilconfig cold-start:
cosmiconfig: includes js-yaml, more loaders
lilconfig: minimal dependencies, faster require() time
For tools that run frequently (ESLint, Prettier on every file save):
lilconfig's smaller footprint reduces startup time slightly
That's why Prettier and PostCSS use lilconfig
c12
c12 — modern config loader from UnJS:
Basic usage
import { loadConfig } from "c12"
// Load config:
const { config } = await loadConfig({
name: "pkgpulse", // Looks for pkgpulse.config.* and .pkgpulserc
defaults: {
outputDir: "dist",
port: 3000,
},
})
TypeScript configs (killer feature)
// pkgpulse.config.ts — user writes TypeScript with full autocomplete:
import { defineConfig } from "pkgpulse"
export default defineConfig({
outputDir: "dist",
port: 4000,
features: {
analytics: true,
auth: false,
},
})
// c12 uses jiti to load .ts files without compilation:
// No `tsc` needed — just write TypeScript and c12 handles it
// Your tool exports a defineConfig helper for type safety:
import type { UserConfig } from "./types"
export function defineConfig(config: UserConfig): UserConfig {
return config // Just a type helper — returns as-is
}
Environment variable merging
import { loadConfig } from "c12"
const { config } = await loadConfig({
name: "pkgpulse",
// Merge environment-specific overrides:
// If NODE_ENV=production, also loads:
// pkgpulse.config.production.ts
// pkgpulse.config.production.json
envName: process.env.NODE_ENV ?? "development",
defaults: {
port: 3000,
debug: false,
},
})
// Result: base config + production overrides merged automatically
Config layers (Nuxt pattern)
import { loadConfig } from "c12"
// Load multiple config layers (extends pattern):
const { config, layers } = await loadConfig({
name: "nuxt",
cwd: projectRoot,
// User's nuxt.config.ts can extend other configs:
// export default defineNuxtConfig({ extends: ['@nuxt/ui'] })
})
// c12 resolves the extends chain, merging all layers
Watchers
import { watchConfig } from "c12"
// Watch config file for changes:
const { config, unwatch } = await watchConfig({
name: "mytool",
onWatch: (event) => {
console.log("Config changed:", event)
// Reload and re-run your tool
},
})
// Stop watching:
unwatch()
Feature Comparison
| Feature | cosmiconfig | lilconfig | c12 |
|---|---|---|---|
| TypeScript configs (native) | ❌ (needs loader) | ❌ | ✅ via jiti |
| YAML support | ✅ | ❌ | ✅ |
| Sync API | ✅ | ✅ Primary | ❌ |
| Async API | ✅ | ✅ | ✅ |
| env-specific configs | ❌ | ❌ | ✅ |
| Config layers/extends | ❌ | ❌ | ✅ |
| Watch mode | ❌ | ❌ | ✅ |
| Ecosystem | ESLint, Prettier, Babel, Jest | Prettier, PostCSS | Nuxt, Nitro, UnJS |
| Bundle size | ~70KB | ~5KB | ~20KB |
| Weekly downloads | ~85M | ~80M | ~5M |
When to Use Each
Choose cosmiconfig if:
- Building a library or tool for the broader npm ecosystem
- Need maximum config file format support (YAML, JSON, JS, CJS, ESM)
- Want compatibility with the same patterns developers already know from ESLint/Prettier
- Need the widest adoption and most documentation/examples
Choose lilconfig if:
- Need synchronous config loading
- Bundle size matters (PostCSS plugins, editor extensions)
- Your tool doesn't need YAML support
- Drop-in replacement for cosmiconfig in existing projects
Choose c12 if:
- Building in the Nuxt/Nitro/UnJS ecosystem
- Want first-class TypeScript config support without custom loaders
- Need environment-specific config merging
- Need config layer/extends patterns
- Building a framework with dev experience features (watch mode, hot reload)
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on cosmiconfig v9.x, lilconfig v3.x, and c12 v2.x.
Compare developer tooling and configuration packages on PkgPulse →