Summary
Building an extensible web application with 120+ shared packages creates a unique challenge: the same import must resolve to different locations during development (TypeScript source files), build time (external dependencies), and runtime (browser-served files). Traditional approaches require maintaining this mapping in three different config formats - TypeScript paths, test runner, and browser import maps - leading to inevitable configuration drift and an entire class of "works in dev, breaks in production" bugs.
Umbraco CMS solves this through code generation: treating package.json exports as a single source of truth and automatically generating all configurations from it. A 200-line transformation script reads the exports field and produces TypeScript paths (with dist-cms → src transformations), browser import maps (with dist-cms → /umbraco/backoffice/ transformations), and test runner configs - all from one edit point. This makes configuration drift architecturally impossible: if TypeScript can find it, the browser can load it.
The result is a pattern that scales to 120+ packages while maintaining correctness guarantees, enabling internal refactoring without breaking consumers, and providing the foundation for a professional, extensible package system built on web standards.
The Problem with Raw File Paths
When building extensions for Umbraco's backoffice, you've probably written code like this:
import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api';
import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification';
But have you ever wondered how this actually works?
You DO install the @umbraco-cms/backoffice NPM package for types, yet the browser doesn't load code from node_modules. Instead, it loads from the Umbraco server. How does TypeScript know about the types during development, while the browser loads different files at runtime?
This is the dual-resolution problem - the same import must resolve to different locations at development time versus runtime. Umbraco solves it elegantly through a three-layer implementation combining TypeScript paths, Vite's external configuration, and browser import maps.
The Traditional Approach (And Why It's Problematic)
Before Umbraco 14, you might have seen Umbraco extensions written with AngularJS having magical service injections based on strings like this:
// ❌ Old approach: magic strings (entityService)
angular.module('umbraco').directive(function (entityService) {
// Do work with entityService
});
You may even have imported modules of your own by virtue of Require.js or similar:
// ❌ Old approach: raw file paths
require('/App_Plugins/MyPackage/utils.js');
This works, but it has several problems:
- Brittle: If you move files, all imports break
- Exposes Implementation: Everyone sees your internal file structure
- Not Refactorable: IDEs can't help you rename or move things
- Unprofessional: Looks like a hack rather than proper module architecture
- Hard to Type: TypeScript struggles to provide IntelliSense for dynamic imports with string paths
The Umbraco Solution: Three Layers for Dual Resolution
Umbraco CMS version 14 and beyond solves the dual-resolution problem (development vs runtime) through a three-layer implementation that makes imports work in different contexts:
- Development (TypeScript): Resolves to source files for type-checking
- Build (Vite/Rollup): Marks as external, leaves imports as-is
- Runtime (Browser): Resolves to built files via importmap
Let's see how each layer works.
Layer 1: TypeScript Resolution
TypeScript needs to know what @umbraco-cms/backoffice/notification means for type-checking.
For Umbraco Core Development (working on the backoffice itself):
{
"compilerOptions": {
"paths": {
"@umbraco-cms/backoffice/notification": ["./src/packages/core/notification/index.ts"]
}
}
}
This maps imports to source files for development on Umbraco itself.
Note for Umbraco core developers: This
tsconfig.jsonis auto-generated frompackage.jsonexports by a build script. See the next section to understand how this works.
For Extension Authors (building backoffice extensions):
Just install the NPM package:
npm install -D @umbraco-cms/backoffice
TypeScript automatically finds type definitions from node_modules/@umbraco-cms/backoffice - no paths config needed!
Result: Full IntelliSense, type-checking, and refactoring support in your IDE.
Why: Because the package.json file contains an "exports" field that defines the public API (more on that later).
Layer 2: Build Resolution (Vite)
When building an extension, Vite needs to know not to bundle Umbraco's code. This is the key step - even though you installed the NPM package, you mark it as external:
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
external: [
/^@umbraco-cms/, // Don't bundle - even though it's in node_modules!
],
},
},
});
This tells Vite: "When you encounter @umbraco-cms/backoffice/*, don't bundle it from node_modules. Leave the import statement as-is in the output."
Result: Your built JavaScript contains import('@umbraco-cms/backoffice/notification') exactly as written - no bundled code from the NPM package. The NPM package was only used for TypeScript types during development.
Note: This is important for versioning. You may, in fact, multi-target your package regardless of the version of the installed NPM package, as it only provides types.
Layer 3: Runtime Resolution (Importmap)
When the browser loads your extension, it needs to know where to find the actual files:
{
"importmap": {
"imports": {
"@umbraco-cms/backoffice/notification": "/umbraco/backoffice/packages/core/notification/index.js"
}
}
}
This is defined in the backoffice's package manifest and tells the browser: "When you see an import for @umbraco-cms/backoffice/notification, load this actual file."
Result: The browser successfully loads the real JavaScript file at runtime.
How It All Works Together
Let's follow an import through all three layers:
You write:
import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification';
During Development:
- TypeScript sees the import
- Finds types from the installed NPM package (
@umbraco-cms/backofficeinnode_modules) - Provides IntelliSense from the
.d.tsfiles - ✅ Full type safety and autocomplete
During Build:
- Vite encounters the import
- Checks
rollupOptions.external- matches/^@umbraco-cms/ -
Doesn't bundle the code from
node_modules- leaves import as-is - ✅ Output contains:
import('@umbraco-cms/backoffice/notification')(no bundled code!)
At Runtime:
- Browser encounters the import
- Checks the importmap
- Resolves to
/umbraco/backoffice/packages/core/notification/index.js - ✅ Loads the actual file
The Innovation: Code Generation from package.json
Now here's where Umbraco's solution gets clever. With 120+ packages in the backoffice, manually maintaining TypeScript paths, Vite externals, and import maps would be a nightmare.
The Scale Problem
Consider what's required for each of those 120+ packages:
-
TypeScript
tsconfig.json: Map@umbraco-cms/backoffice/notificationto./src/packages/core/notification/index.ts - Vite config: Mark as external (regex pattern handles this automatically)
-
Runtime
umbraco-package.json: Map@umbraco-cms/backoffice/notificationto/umbraco/backoffice/packages/core/notification/index.js - Test runner configs: Various formats for Web Test Runner
That's 120+ entries in tsconfig.json, 120+ entries in the import map, and keeping them all in sync. A single typo breaks everything. Adding a new package requires updating multiple files in different formats.
This is untenable.
Umbraco's Solution: Single Source of Truth
Umbraco solves this with code generation, treating package.json as the golden source of truth:
package.json exports (source of truth)
↓
devops/importmap/index.js (reads and transforms)
↓
├→ devops/tsconfig/index.js → tsconfig.json (120+ paths)
├→ devops/build/create-umbraco-package.js → umbraco-package.json (import map)
└→ Test runner configuration
One edit point. Four outputs. Always in sync.
Why package.json as the Source?
The package.json already defines the public API contract with its exports field:
{
"name": "@umbraco-cms/backoffice",
"exports": {
".": null,
"./notification": "./dist-cms/packages/core/notification/index.js",
"./content": "./dist-cms/packages/content/content/index.js",
"./modal": "./dist-cms/packages/core/modal/index.js"
// ... 117+ more packages
}
}
Why this is the perfect source:
- ✅ Already required: Published to npm, consumed by TypeScript naturally
- ✅ Defines the contract: Lists every public import path
- ✅ Standard format: Uses Node.js package exports (not a custom format)
- ✅ Single edit point: Add/remove/rename a package in one place
The ".": null is intentional - it prevents importing the root package and forces consumers to use specific subpaths like @umbraco-cms/backoffice/notification.
Implementation: The Transformation Pipeline
Here's how Umbraco's build scripts transform this single source into multiple formats.
Step 1: Create Import Map Structure
The devops/importmap/index.js script reads package.json exports and creates an intermediate import map:
export const createImportMap = (args) => {
const imports = {
...args.additionalImports,
};
// Iterate over the exports in package.json
for (const [key, value] of Object.entries(packageJsonExports || {})) {
// Skip null exports and non-JS files
if (value && value.endsWith('.js')) {
const moduleName = key.replace(/^\.\//, '');
// Transform the path based on context
let modulePath = value;
if (typeof args.rootDir !== 'undefined') {
modulePath = modulePath.replace(/^\.\/dist-cms/, args.rootDir);
}
if (args.replaceModuleExtensions) {
modulePath = modulePath.replace('.js', '.ts');
}
const importAlias = `${packageJsonName}/${moduleName}`;
imports[importAlias] = modulePath;
}
}
return { imports };
};
Step 2: Generate TypeScript Paths
The devops/tsconfig/index.js script uses createImportMap() with transformations for TypeScript:
const importmap = createImportMap({
rootDir: './src', // dist-cms → src
additionalImports: {
'@umbraco-cms/internal/test-utils': './utils/test-utils.ts',
},
replaceModuleExtensions: true, // .js → .ts
});
// Convert to tsconfig.json format
const paths = {};
for (const [key, value] of Object.entries(importmap.imports)) {
paths[key] = [value]; // tsconfig expects arrays
}
tsConfigBase.compilerOptions.paths = paths;
Generated output (tsconfig.json):
{
"compilerOptions": {
"paths": {
"@umbraco-cms/backoffice/notification": ["./src/packages/core/notification/index.ts"],
"@umbraco-cms/backoffice/content": ["./src/packages/content/content/index.ts"],
"@umbraco-cms/backoffice/modal": ["./src/packages/core/modal/index.ts"]
// ... 117+ more
}
}
}
Step 3: Generate Runtime Import Map
The devops/build/create-umbraco-package.js script transforms for browsers:
const importmap = createImportMap({
rootDir: '/umbraco/backoffice', // dist-cms → server path
replaceModuleExtensions: false, // Keep .js for runtime
});
// Write to umbraco-package.json
const manifest = {
name: '@umbraco-cms/backoffice',
version: packageVersion,
importmap: {
imports: importmap.imports,
},
};
Generated output (umbraco-package.json):
{
"name": "@umbraco-cms/backoffice",
"importmap": {
"imports": {
"@umbraco-cms/backoffice/notification": "/umbraco/backoffice/packages/core/notification/index.js",
"@umbraco-cms/backoffice/content": "/umbraco/backoffice/packages/content/content/index.js",
"@umbraco-cms/backoffice/modal": "/umbraco/backoffice/packages/core/modal/index.js"
// ... 117+ more
}
}
}
The Key Transformations
From the same package.json export "./notification": "./dist-cms/packages/core/notification/index.js", we generate:
| Context | Transformation | Output |
|---|---|---|
| TypeScript |
dist-cms → src.js → .ts
|
./src/packages/core/notification/index.ts |
| Runtime |
dist-cms → /umbraco/backoffice/Keep .js
|
/umbraco/backoffice/packages/core/notification/index.js |
| Package Name | Prepend package name and subpath | @umbraco-cms/backoffice/notification |
Same logical structure. Different physical paths for different contexts.
Build Pipeline Integration
These scripts run at specific points in the development lifecycle:
During development:
npm run generate:tsconfig # Regenerates tsconfig.json from package.json
Before build:
npm run generate:manifest # Generates umbraco-package.json with import map
In CI/CD:
npm run package:validate # Validates that exports, paths, and imports are in sync
Adding a new package:
- Edit
package.json: Add one line toexports - Run
npm run generate:tsconfig - Run
npm run generate:manifest - Done ✅
No manual editing of configs. No risk of typos or drift.
The Result: Impossible to Have Config Drift
Because everything is generated from the same source, it's architecturally impossible for configs to drift out of sync. If TypeScript can find it, the browser can load it. If the import map has it, TypeScript knows about it.
This eliminates an entire class of bugs:
- ❌ Import works in TypeScript but fails at runtime
- ❌ Import works at runtime but TypeScript can't find types
- ❌ Test runner can't resolve import that works everywhere else
With code generation, these bugs cannot exist.
Why This Architecture Works at Scale
The code generation approach provides significant advantages in production:
1. Eliminates Config Drift
Manual maintenance inevitably leads to drift. A developer adds a TypeScript path but forgets the import map. Tests pass locally but production breaks.
With code generation, this is impossible. One source of truth means one failure mode: if package.json is wrong, everything is wrong. Easy to catch, easy to fix.
2. Maintainability
Manual approach:
- 120 packages × 3 configs = 360 entries to maintain
- Each in a different format
- Spread across 4+ files
Generated approach:
- 120 entries in
package.json(source of truth) - 1 script that generates everything else
- ~200 lines of transformation logic (vs 360+ lines of config)
3. Type Safety
TypeScript provides full autocomplete and refactoring support. If you rename a package in package.json, regenerate configs, and TypeScript immediately shows you every place that needs updating.
4. Refactorability
Internal file structure can change without breaking consumers. As long as package.json exports stay the same, consumers see a stable API. The generation scripts handle the internal path changes.
Example: Moving notification from packages/core/ to packages/alerts/ requires:
- Updating one path in
package.json - Regenerating configs
- Zero changes for extension authors
The NPM Package: Types and Code, But Not Bundled
Here's where the pattern gets clever. Umbraco publishes an NPM package that contains both runtime JavaScript and TypeScript definitions:
npm install -D @umbraco-cms/backoffice
Extension authors install this package, but here's the trick: they mark it as external in vite.config.ts (as shown above), which prevents Vite from bundling the runtime code even though it's present in node_modules.
This means:
- TypeScript uses the package for types and IntelliSense during development
- Vite sees it's external and doesn't bundle it
- The browser loads the actual runtime code from the server at runtime
The complete picture:
- Development: TypeScript finds types from the installed NPM package
-
Build: Vite sees
externalconfig and doesn't bundle the code fromnode_modules -
Runtime: Browser uses importmap to load actual files from
/umbraco/backoffice/
The magic: Same NPM package name (@umbraco-cms/backoffice/*), but:
- In development → resolved to types from
node_modulesfor IntelliSense - At build → marked external, not bundled
- At runtime → resolved to server files via importmap
Why Alternatives Don't Work
At this point, you might be wondering: "If import maps create all this config duplication, why use them at all? Are there better alternatives?"
It's a fair question. Let's consider what else we could have done.
Alternative 1: Traditional NPM Package (Bundle Our Code)
What if we published the runtime code to NPM and let extension authors bundle it?
npm install @umbraco-cms/backoffice
Extension authors would import and bundle our code into their extensions.
Why this doesn't work:
- ❌ Code duplication: Every extension bundles the same 22kb of Search code
- ❌ Version conflicts: Extension A uses Search v1.0, Extension B uses v1.1 - both loaded
- ❌ No shared state: Global contexts can't communicate across separately bundled instances
- ❌ Bundle bloat: The backoffice loads the same code 5 times for 5 extensions
- ❌ Defeats code-splitting: We carefully split global/core bundles - all wasted
Verdict: This defeats the entire architectural purpose. We'd be back to the jQuery plugin days where every extension bundles duplicate dependencies.
Alternative 2: Raw File Paths (Skip the Abstraction)
What if we skipped logical imports and used raw paths?
// Extension authors would write:
api: () => import('/umbraco/backoffice/packages/content/index.js').then((m) => ({ default: m.UmbContentRepository }));
Why this doesn't work:
- ❌ Brittle: If we reorganize files, every extension breaks
- ❌ No abstraction: Exposes implementation details to consumers
- ❌ No TypeScript support: How do extension authors get types?
- ❌ Unprofessional: Looks like a hack rather than proper package architecture
- ❌ Non-refactorable: IDEs can't help with renames or moves
Alternative 3: Custom Vite Plugin
What if we built a Vite plugin that understands Umbraco packages?
import { umbracoPlugin } from '@umbraco-cms/vite-plugin';
export default defineConfig({
plugins: [
umbracoPlugin({
externalPackages: ['@umbraco-cms/backoffice'],
}),
],
});
The plugin could auto-configure externals, generate TypeScript paths, and validate imports.
Why this doesn't solve it:
- ⚠️ Still need import maps: Browsers still need runtime resolution somehow
- ⚠️ Another tool to maintain: We'd have to build, document, and support it
- ⚠️ Adoption barrier: Extension authors must learn our custom tooling
- ✅ Could reduce duplication: Might generate configs from a single source
Verdict: This could be a nice developer experience improvement, but it doesn't eliminate the fundamental need for import maps at runtime.
The Real Problem: Ecosystem Gap, Not the Pattern
Here's the critical insight: The problem isn't import maps - it's that the tooling ecosystem hasn't standardized on them yet.
Import maps are:
- ✅ Browser-native: WHATWG standard, shipped in all modern browsers
- ✅ Semantically correct: Designed exactly for this use case
- ✅ The future: The direction web standards are moving
But JavaScript tooling is:
- ❌ Behind the curve: TypeScript, Vite, NPM don't understand import maps
- ❌ Fragmented: Each tool has its own config format for the same concept
- ❌ Slow to adapt: Browser standards move faster than tooling
We're not working around a limitation of import maps - we're working around tooling that hasn't caught up to the browser standard.
What Our Constraints Actually Are
When building a package for third-party extension authors, we must support:
- Type definitions at development time - Extension authors need IntelliSense
- No runtime bundling - Can't have code duplication and version conflicts
- Shared runtime state - Global contexts must be singletons
- Standard tools - Must work with TypeScript, Vite, standard browsers
- Clean API - Professional package names, not raw file paths
Import maps are the only solution that satisfies all five constraints.
What Would Actually Improve Things?
Umbraco can control:
- ✅ Build code generation scripts (we did this)
- ✅ Document the pattern for extension authors
- ✅ Provide templates and examples
- ⚠️ Build Vite plugins to improve DX (nice-to-have, doesn't eliminate import maps)
The ecosystem needs:
- ❌ Native import map support in TypeScript
- ❌ Native import map support in Vite/Rollup
- ❌ Standardized tooling that treats import maps as first-class
- ❌ Better coordination between browser standards and build tool vendors
Until the tooling catches up, patterns like Umbraco's code generation are the pragmatic solution.
Conclusion
The import map pattern is more than just a clever trick - it's a fundamental architectural pattern that solves the dual-resolution problem (development vs runtime) elegantly through a three-layer implementation. By using logical module identifiers with different resolution strategies for TypeScript, Vite, and browsers, Umbraco creates a professional, maintainable, and extensible package system.
The real innovation is treating package.json as a single source of truth and generating all configs from it. This eliminates an entire class of bugs, improves developer velocity, and makes it architecturally impossible for configs to drift out of sync.
For 120+ packages, code generation isn't just an optimization - it's a requirement. Manual maintenance at this scale is untenable.
The three-layer system (TypeScript paths → Vite externals → import maps) might seem complex at first, but once you understand how each layer works, you'll appreciate the elegance of the solution. It's a pattern that makes modern web development with extensible packages possible.
Appendix A: Building Extensions with This Pattern
Note: The following sections provide guidance for developers who want to build Umbraco extensions using this pattern.
You can use the same pattern for your own Umbraco packages. Here's how you might implement it for a Search package:
1. Choose Your Namespace
Use @organization/your-package to stay within a self-defined scope, if you eventually publish this to an NPM feed.
@organization/search/core
@organization/search/global
2. Configure TypeScript (tsconfig.json)
Map logical imports to your source files:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@organization/search/global": ["./src/global/index.ts"],
"@organization/search/core": ["./src/core/index.ts"]
}
}
}
3. Configure Vite (vite.config.ts)
Mark your package as external so it's not self-bundled:
export default defineConfig({
build: {
lib: {
entry: {
'search-global': 'src/global/search-global.ts',
'search-core': 'src/core/search-core.ts',
},
formats: ['es'],
},
rollupOptions: {
external: [/^@umbraco-cms/, /^@organization/],
},
},
});
4. Add Importmap (umbraco-package.json)
Tell browsers where to find your built files:
{
"id": "Your.Package.Id",
"name": "@organization/search",
"extensions": [
{
"type": "bundle",
"alias": "Your.Package.Bundle",
"js": "/App_Plugins/YourPackage/bundle.js"
}
],
"importmap": {
"imports": {
"@organization/search/global": "/App_Plugins/YourPackage/search-global.js",
"@organization/search/core": "/App_Plugins/YourPackage/search-core.js"
}
}
}
Note how we use the same namespace scope in the importmap as in TypeScript and package.json. This way everything aligns.
5. Use Logical Imports in Manifests
Now use logical imports instead of raw paths:
// ✅ New approach: logical imports
export const manifests: Array<UmbExtensionManifest> = [
{
type: 'repository',
alias: 'My.Repository',
api: () => import('@organization/search/core').then((m) => ({ default: m.MyRepository })),
},
];
// ✅ New approach: import values
import { UMB_SEARCH_CONTEXT_TOKEN } from '@organization/search/global';
console.log(UMB_SEARCH_CONTEXT_TOKEN); // logs: context token instance
6. Export Your Types (package.json)
If you want others to extend your package, publish TypeScript definitions:
{
"name": "@organization/search",
"version": "1.0.0",
"type": "module",
"exports": {
"./core": {
"types": "./dist/core/index.d.ts",
"default": "./dist/core/index.js"
},
"./global": {
"types": "./dist/global/index.d.ts",
"default": "./dist/global/index.js"
}
}
}
When to Add Code Generation
For a small package with 2-3 subpaths, manual config maintenance is fine. But consider building a generation script (like Umbraco's) when:
- ✅ You have 4+ subpaths
- ✅ You're frequently adding new exports
- ✅ You're building a package ecosystem
- ✅ You want others to extend your work
- ✅ Config drift has caused bugs
The script doesn't need to be complex - just read package.json exports and generate the various config formats. You can use Umbraco's scripts as a reference: devops/importmap/index.js
Appendix B: Advanced Usage - Multiple Subpaths
You can split your package into multiple logical subpaths:
import { GlobalContext } from '@organization/search/global';
import { Repository } from '@organization/search/core';
import { Helpers } from '@organization/search/utils';
Each subpath can have different loading characteristics:
- global: Loaded upfront for event listeners
- core: Lazy-loaded on demand
- utils: Shared utilities
Just add each to your importmap:
{
"importmap": {
"imports": {
"@organization/search/global": "/App_Plugins/Search/global.js",
"@organization/search/core": "/App_Plugins/Search/core.js",
"@organization/search/utils": "/App_Plugins/Search/utils.js"
}
}
}
Benefits of Multiple Subpaths
- Code splitting: Users only load what they use
- Lazy loading: Core functionality loads on-demand
- Clear organization: API surface is explicit
- Independent updates: Update one subpath without affecting others
Appendix C: Common Pitfalls
1. Forgetting to Mark as External
If you don't mark your package as external in Vite config, it will bundle itself, breaking the lazy-load pattern:
// ❌ Wrong: Will self-bundle
rollupOptions: {
external: [/^@umbraco-cms/]; // Missing your own package!
}
// ✅ Correct: Mark your own package as external
rollupOptions: {
external: [
/^@umbraco-cms/,
/^@organization/, // Add this!
];
}
2. Mismatched Paths
The tsconfig path must point to the exports file (usually index.ts), not the entry file:
// ❌ Wrong: Points to entry file
"@organization/search/core": ["./src/core/search-core.ts"]
// ✅ Correct: Points to exports file
"@organization/search/core": ["./src/core/index.ts"]
3. Missing Importmap Entry
If you add a new subpath, don't forget to add it to the importmap:
{
"importmap": {
"imports": {
"@organization/search/global": "/App_Plugins/Search/global.js",
"@organization/search/core": "/App_Plugins/Search/core.js"
// Don't forget to add new subpaths here!
}
}
}
4. Config Drift
Manually maintaining multiple configs leads to drift. If you have more than a few subpaths, consider implementing code generation like Umbraco does.
Further Reading:

Top comments (0)