Skip to main content

Guide

cosmiconfig vs lilconfig vs c12 2026

Compare cosmiconfig, lilconfig, and c12 for loading tool configuration files from multiple locations. JSON/YAML/JS config support, TypeScript configs, search.

·PkgPulse Team·
0

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

Featurecosmiconfiglilconfigc12
TypeScript configs (native)❌ (needs loader)✅ via jiti
YAML support
Sync API✅ Primary
Async API
env-specific configs
Config layers/extends
Watch mode
EcosystemESLint, Prettier, Babel, JestPrettier, PostCSSNuxt, 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)

How Major Tools Use Config Loaders in Practice

The download numbers for cosmiconfig (~85M/week) and lilconfig (~80M/week) are inflated by transitive dependencies — you almost certainly have both in your node_modules without having installed either directly. ESLint, Babel, Jest, Stylelint, and Prettier all depend on one of these two, meaning every JavaScript project with a linter or formatter pulls them in. Understanding the design choices each tool made is more useful than the raw counts.

ESLint switched from cosmiconfig to its own flat config system in v9, but the historical choice of cosmiconfig explains why ESLint config was so flexible — .eslintrc, .eslintrc.json, .eslintrc.js, eslint.config.js, and the "eslintConfig" key in package.json were all valid because cosmiconfig's search order handles all of them. Prettier chose lilconfig because it only needs to find a config file and read it synchronously; it doesn't need YAML support (Prettier configs are JSON or JavaScript objects), and every millisecond of startup time matters when Prettier runs on every file save. PostCSS has the same reasoning — hundreds of plugin invocations per build means synchronous config loading with minimal overhead is the right trade-off.

For library authors building new CLI tools in 2026, cosmiconfig remains the standard choice because it's what developers expect. If your tool is named mytool, developers will try mytool.config.js, .mytoolrc, and the package.json "mytool" key — all of which cosmiconfig handles with a single cosmiconfig("mytool").search() call. Adding TypeScript config support via a custom jiti loader adds roughly 10 lines of setup code.

c12's Layer System and Why Nuxt Uses It

The extends concept in c12 is borrowed from Nuxt's layer system, where a project can compose configuration from multiple sources — a base config, a UI kit package, and project-specific overrides — all merged in a defined priority order. This is fundamentally different from cosmiconfig's single-file discovery model. With c12, a nuxt.config.ts can declare extends: ['@nuxt/ui-pro', './base.config.ts'], and c12 resolves each layer, deep-merges their configurations, and presents a unified config object to the framework. The individual layers can live in npm packages or local directories, and each can have its own TypeScript config file with full type checking.

This pattern is powerful for internal tooling in large organizations. A company's shared build tool can have a base config in a private npm package (@company/build-config) that sets sensible defaults for TypeScript, bundle output, and linting rules, and each project extends it with minimal overrides. c12's loadConfig automatically resolves the extends chain when it encounters an array of config sources. The watch mode (watchConfig) reloads all layers when any of them changes, which enables a hot-reload developer experience even with multi-layer configurations.

Validating Config Files with Zod

A config loader finds and parses the config file, but it doesn't validate whether the loaded values match your expected schema. All three libraries return plain objects, and runtime type errors from unexpected config values can produce confusing error messages deep in your tool's execution. The best practice is to pipe the loaded config through Zod (or another schema validator) immediately after loading.

With cosmiconfig or lilconfig, the pattern is straightforward: call .search(), get the result.config object, and pass it to ConfigSchema.parse(). If the user provided { format: "esnext" } instead of the valid "esm" | "cjs" | "both" enum, Zod throws a ZodError with a clear message pointing to the invalid field. c12 integrates naturally with this pattern as well, but it also supports a defaults option that pre-populates missing fields before you validate — meaning Zod's .parse() only needs to validate types, not handle missing defaults. For TypeScript config files loaded via c12's jiti integration, the TypeScript compiler already catches type mismatches during development, making Zod validation a defense-in-depth measure for runtime safety rather than the primary validation layer.


Performance in Watch Mode and Development Servers

In development servers and watch-mode tools, config files are re-read whenever the tool restarts or detects file changes. cosmiconfig's built-in caching (enabled by default) means subsequent explorer.search() calls return the cached result without hitting the filesystem again. This matters for tools like ESLint or Prettier that invoke config loading on every file change — without caching, each save event would re-read the config file from disk and re-parse it. Call explorer.clearCaches() when you detect that the config file itself has changed (using fs.watch on the config path). lilconfig has the same caching behavior. c12's watchConfig() function handles this automatically — it watches the config file for changes and calls your onWatch callback when a change is detected, then you can call loadConfig() again to get the fresh values. For framework development servers that support hot config reloading, c12's integrated watch mode is significantly easier to implement correctly than manually managing cache invalidation with cosmiconfig.

Security Considerations for Config File Loading

Config file loaders that support JavaScript config files (.js, .mjs, .ts) introduce an execution surface that purely static loaders avoid. When cosmiconfig loads a mytool.config.js file, it executes the module using import() or require(), meaning any code in that file runs with full Node.js privileges. This is intentional — it enables dynamic config generation using environment variables, reading other files, or calling helper functions — but it also means that a compromised config file in a repository can execute arbitrary code during a build or tool invocation. For CLI tools that load config files from untrusted directories (e.g., tools that process packages from npm), this is a genuine attack surface. c12's TypeScript support via jiti has the same property: executing a .ts config file means running the transpiled output. Mitigations include only loading config files from expected locations (the project root), validating the loaded config against a strict schema before using any values, and being explicit in documentation that config files execute code and must be treated with the same trust as source code in the repository.

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 →

See also: cac vs meow vs arg 2026 and cosmiconfig vs lilconfig vs conf, archiver vs adm-zip vs JSZip (2026).

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.