nextjs-cms

Config Loading

How cms.config is found, validated with Zod, merged with defaults, cached, and hot-reloaded.

Code: packages/nextjs-cms/src/core/config/

FileRole
config-loader.tsThe Zod schema, defineConfig, defaults, mergeConfig, getCMSConfig()
loader.tsRe-exports the active loader implementation (loader-with-jiti.ts)
loader-with-jiti.tsFinds and imports the file with jiti, watches it, bumps the config version
loader-with-esbuild.tsAlternative loader kept for reference

getCMSConfig()

Every consumer (factories, fields, routers, the CLI) calls:

import { getCMSConfig } from 'nextjs-cms/core/config'

const config = await getCMSConfig() // Readonly<ComputedCMSConfig>

Flow:

getCMSConfig()
  ├─ cached && cachedVersion === getConfigImportVersion() ? → return cached
  ├─ loadConfigModule()          find cms.config.{js,cjs,mjs,json,ts,cts,mts} in process.cwd(), import with jiti
  ├─ cmsConfigSchema.parse()     Zod validation (throws on invalid values)
  ├─ mergeConfig(defaults, user) → ComputedCMSConfig (every field present)
  │    ├─ normalizeUI()          branding precedence rules
  │    ├─ normalizeI18n()        registers dictionaries in the dict store, validates fallbackLanguage
  │    └─ normalizeLocalization() duplicate codes, defaultLocale membership
  └─ cache with the current version

Validation errors from the normalize* step are printed with context and exit the process (process.exit(1)): a broken config can't produce a working admin.

Input vs. computed types

  • CMSConfig is the input type (inferred from the Zod schema): everything optional.
  • ComputedCMSConfig is the output: every option resolved, so consumers never write ?? default themselves.

When you add an option:

  1. Add it to cmsConfigSchema with a JSDoc comment (it shows up in users' editors).
  2. Add it to ComputedCMSConfig.
  3. Add its default to defaultConfig.
  4. Merge it in mergeConfig.
  5. Document it in cms.config.ts reference.

defineConfig

An identity function with generics that make the config type-safe:

  • localization.defaultLocale is constrained to the declared locale codes (const Codes extends string).
  • i18n.fallbackLanguage is constrained to the keys of supportedLanguages.
  • plugins entries are normalized from either a descriptor or { plugin, options, override, groupRoutes } into { package, ... } runtime entries, and override.routes keys are type-checked against the plugin's declared route paths.

Hot reload in development

The loader starts a chokidar watcher on cms.config.* (never in production). On change:

  1. The jiti cache entry for the file is cleared.
  2. A global config version counter is incremented (getConfigImportVersion()).
  3. A "hot marker" file is rewritten to trigger Next.js Fast Refresh.

Everything that caches derived data compares its cached version with getConfigImportVersion(): the config itself, SectionFactory, and the merged tRPC router in createTRPCRouter(). Bumping the version invalidates all of them at once.

Good to know:

Watcher and version state live on globalThis under Symbol.for('nextjs-cms.configWatcher'), so they survive module re-evaluation during Next.js HMR and are shared between the admin's bundle and jiti-loaded code.

Pitfalls

  • Unknown top-level keys are stripped, not rejected (z.object, not z.strictObject). Nested objects like ui are strict. Users are protected by defineConfig's types.
  • Relative paths (sections.path, media.upload.path, watermark imagePath) resolve against process.cwd(), which is the project root for both next dev and the CLI.
  • Don't import the config module directly from app code; always go through getCMSConfig() so caching and validation apply.