Skip to main content

cosmiconfig vs lilconfig vs c12: Config File Loading in Node.js (2026)

·PkgPulse Team

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)

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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.