Config Loading
How cms.config is found, validated with Zod, merged with defaults, cached, and hot-reloaded.
Code: packages/nextjs-cms/src/core/config/
| File | Role |
|---|---|
config-loader.ts | The Zod schema, defineConfig, defaults, mergeConfig, getCMSConfig() |
loader.ts | Re-exports the active loader implementation (loader-with-jiti.ts) |
loader-with-jiti.ts | Finds and imports the file with jiti, watches it, bumps the config version |
loader-with-esbuild.ts | Alternative 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 versionValidation 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
CMSConfigis the input type (inferred from the Zod schema): everything optional.ComputedCMSConfigis the output: every option resolved, so consumers never write?? defaultthemselves.
When you add an option:
- Add it to
cmsConfigSchemawith a JSDoc comment (it shows up in users' editors). - Add it to
ComputedCMSConfig. - Add its default to
defaultConfig. - Merge it in
mergeConfig. - Document it in
cms.config.tsreference.
defineConfig
An identity function with generics that make the config type-safe:
localization.defaultLocaleis constrained to the declared locale codes (const Codes extends string).i18n.fallbackLanguageis constrained to the keys ofsupportedLanguages.pluginsentries are normalized from either a descriptor or{ plugin, options, override, groupRoutes }into{ package, ... }runtime entries, andoverride.routeskeys 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:
- The jiti cache entry for the file is cleared.
- A global config version counter is incremented (
getConfigImportVersion()). - 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.
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, notz.strictObject). Nested objects likeuiare strict. Users are protected bydefineConfig's types. - Relative paths (
sections.path,media.upload.path, watermarkimagePath) resolve againstprocess.cwd(), which is the project root for bothnext devand the CLI. - Don't import the config module directly from app code; always go through
getCMSConfig()so caching and validation apply.