DEV Community

Cover image for Package.json as Single Source of Truth: How Umbraco Auto-Generates TypeScript Paths and Browser Import Maps
Jacob Overgaard
Jacob Overgaard

Posted on

Package.json as Single Source of Truth: How Umbraco Auto-Generates TypeScript Paths and Browser Import Maps

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';
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

This works, but it has several problems:

  1. Brittle: If you move files, all imports break
  2. Exposes Implementation: Everyone sees your internal file structure
  3. Not Refactorable: IDEs can't help you rename or move things
  4. Unprofessional: Looks like a hack rather than proper module architecture
  5. 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:

  1. Development (TypeScript): Resolves to source files for type-checking
  2. Build (Vite/Rollup): Marks as external, leaves imports as-is
  3. 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"]
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This maps imports to source files for development on Umbraco itself.

Note for Umbraco core developers: This tsconfig.json is auto-generated from package.json exports 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
Enter fullscreen mode Exit fullscreen mode

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!
            ],
        },
    },
});
Enter fullscreen mode Exit fullscreen mode

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"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

During Development:

  1. TypeScript sees the import
  2. Finds types from the installed NPM package (@umbraco-cms/backoffice in node_modules)
  3. Provides IntelliSense from the .d.ts files
  4. ✅ Full type safety and autocomplete

During Build:

  1. Vite encounters the import
  2. Checks rollupOptions.external - matches /^@umbraco-cms/
  3. Doesn't bundle the code from node_modules - leaves import as-is
  4. ✅ Output contains: import('@umbraco-cms/backoffice/notification') (no bundled code!)

At Runtime:

  1. Browser encounters the import
  2. Checks the importmap
  3. Resolves to /umbraco/backoffice/packages/core/notification/index.js
  4. ✅ 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:

  1. TypeScript tsconfig.json: Map @umbraco-cms/backoffice/notification to ./src/packages/core/notification/index.ts
  2. Vite config: Mark as external (regex pattern handles this automatically)
  3. Runtime umbraco-package.json: Map @umbraco-cms/backoffice/notification to /umbraco/backoffice/packages/core/notification/index.js
  4. 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
Enter fullscreen mode Exit fullscreen mode

Diagram showing package.json exports at the top flowing through devops/importmap/index.js script in the middle, which outputs to three targets at the bottom: tsconfig.json for TypeScript paths, umbraco-package.json for browser import maps, and test runner configuration files. Arrows show the transformation from a single source to multiple config formats.

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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 };
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
    },
};
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The Key Transformations

From the same package.json export "./notification": "./dist-cms/packages/core/notification/index.js", we generate:

Context Transformation Output
TypeScript dist-cmssrc
.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
Enter fullscreen mode Exit fullscreen mode

Before build:

npm run generate:manifest  # Generates umbraco-package.json with import map
Enter fullscreen mode Exit fullscreen mode

In CI/CD:

npm run package:validate   # Validates that exports, paths, and imports are in sync
Enter fullscreen mode Exit fullscreen mode

Adding a new package:

  1. Edit package.json: Add one line to exports
  2. Run npm run generate:tsconfig
  3. Run npm run generate:manifest
  4. 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
Enter fullscreen mode Exit fullscreen mode

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 external config and doesn't bundle the code from node_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_modules for 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
Enter fullscreen mode Exit fullscreen mode

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 }));
Enter fullscreen mode Exit fullscreen mode

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'],
        }),
    ],
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. Type definitions at development time - Extension authors need IntelliSense
  2. No runtime bundling - Can't have code duplication and version conflicts
  3. Shared runtime state - Global contexts must be singletons
  4. Standard tools - Must work with TypeScript, Vite, standard browsers
  5. 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
Enter fullscreen mode Exit fullscreen mode

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"]
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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/],
        },
    },
});
Enter fullscreen mode Exit fullscreen mode

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"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Benefits of Multiple Subpaths

  1. Code splitting: Users only load what they use
  2. Lazy loading: Core functionality loads on-demand
  3. Clear organization: API surface is explicit
  4. 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!
    ];
}
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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!
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)