Omnissa logo
Engineering
UI

Lazy Loading Angular Standalone Components Across Nx Libraries

KKris Yankov
February 27th, 2026
Lazy Loading Angular Standalone Components Across Nx Libraries

Introduction

Product and engineering teams often need the same view, modal, or component to appear in different places — for example, when visualizing relationships between entities that exist in different Nx monorepo libraries. Reusing the same UI in these contexts keeps behavior and UX consistent, but it can conflict with keeping bundles small and avoiding circular dependencies across Nx libraries.

This article describes a pattern for Lazy Loading Angular Standalone Components from one Nx library into another. This approach enables code splitting and on-demand loading of feature components across library boundaries, so the same component can be rendered in multiple contexts without duplicating logic or pulling heavy dependencies in every consumer.


The Problem

In an Nx Monorepo, we often organize code by domain or feature. Consider the following scenario:

Feature A (libs/feature-a) needs to display a complex component from Feature B (libs/feature-b).

The complex component:

The Problem

Now let's go over the first solutions that come to mind.


Issues with Direct Import

If Feature A directly imports the component from Feature B:

// Direct import creates tight coupling
import { LargeComplexComponent } from '@myorg/feature-b';

Main Problems


Issues with Shared Feature Library

One might consider extracting the needed components into a shared library (e.g., feature-b-common) to avoid direct cross-domain dependencies. However, this might create new problems:


Other Considered Approaches

When considering approaches for lazy loading standalone components with their dependencies (NgRx Store, translation modules, etc.) across Nx libraries, several alternatives exist. Here's a detailed comparison:

1. Direct await import() + EnvironmentInjector

EnvironmentInjector: Angular's hierarchical injector that provides dependency injection context for dynamically created components. Unlike the legacy Injector, it can create standalone components and automatically load their environment (imports array). When we pass an EnvironmentInjector to createComponent(), the new component can access all services and dependencies available in that injector's scope.

Approach:

@Component({
  ...
})
export class FeatureADashboardComponent {
  ...
  // Inject the current environment's EnvironmentInjector
  private readonly envInjector = inject(EnvironmentInjector);
  // Get reference to the template container where we'll render the lazy component
  @ViewChild('previewContainer', { read: ViewContainerRef })
  previewContainer!: ViewContainerRef;
  ...
  public async lazyLoadComponent(entity: Entity): Promise<void> {
    // Step 1: Create an EnvironmentInjector with Feature B's dependencies
    const featureInjector = createEnvironmentInjector([
      provideState(FEATURE_B_STORE_KEY, featureBReducerMap),
      provideEffects(FEATURE_B_EFFECTS),
      importProvidersFrom(TranslateModule.forChild({
        loadBundle,
        localeBundleId: LocaleBundleId.FEATURE_B,
      })),
    ], this.envInjector);

    // Step 2: Dynamically import the component at runtime
    const { FeatureBPreviewComponent } = await import(
      '@myorg/feature-b/components/preview-panel'
    );

    // Step 3: Clear any existing component in the container
    this.previewContainer.clear();

    // Step 4: Create the component imperatively, passing the EnvironmentInjector
    const componentRef = this.previewContainer.createComponent(
      FeatureBPreviewComponent,
      { 
        environmentInjector: featureInjector,
        bindings: [
          inputBinding('entityId', entity?.id),
          inputBinding('displayMode', 'modal'),
          outputBinding('closed', () => {
            this.previewContainer.clear();
          }),
        ],
      }
    );
  }
}

Pros:

Cons:


2. Angular @defer

Deferrable views, also known as @defer blocks, reduce the initial bundle size by deferring both the loading of the component’s code and its rendering until the block is triggered. You must import the component so the template can reference it, but the Angular compiler detects that it is only used inside a @defer block and emits it (along with its transitive dependencies) into a separate JavaScript chunk that is loaded only when the block is triggered. Those transitive dependencies need not be standalone.

Approach:

@defer (on viewport) {
  <feature-b-component [data]="data" />
} @loading {
  <loading-spinner />
} @error {
  <error-message />
}

Pros:

Cons:


3. Module Wrappers

Create module wrappers for the lazy-loaded components that encapsulate all of their dependencies.

Approach:

// Feature module wrapper
@NgModule({
  imports: [
    StoreModule.forFeature('feature', featureReducer),
    EffectsModule.forFeature(effects),
    TranslateModule.forChild({...})
  ],
  declarations: [PreviewComponent],
  exports: [PreviewComponent]
})
export class PreviewModule {
  // Method to resolve the component type
  resolveComponent() {
    return PreviewComponent;
  }
}

// Usage
async loadComponent(elementRef: ElementRef, props: any) {
  //  Dynamic import the module
  const module = await import('@myorg/feature-b/preview.module');
  
  // Create module instance with parent injector  
  const moduleRef = createNgModule(module.PreviewModule, this.injector);
  
  //  Get component type from module
  const componentType = moduleRef.instance.resolveComponent();
  
  // Create component with module's injector passed to
  // Angular's createComponent() function from @angular/core
  const componentRef = createComponent(componentType, {
    hostElement: elementRef.nativeElement,
    environmentInjector: moduleRef.injector 
  });
  
  // Attach to Angular change detection
  this.appRef.attachView(componentRef.hostView);
  
  // Set input properties
  componentRef.setInput('entityId', props.entityId);
  componentRef.setInput('displayMode', props.displayMode);
  
  // Listen to outputs
  componentRef.instance.closed.subscribe(() => {
    componentRef.destroy();
  });
  
  return componentRef;
}

Pros:

Cons:


4. Web Components (Custom Elements)

Custom Elements—Angular components packaged as custom elements (also called Web Components) — are a web standard for defining new HTML elements in a framework-agnostic way.

Approach:

@Component({
  selector: 'app-feature-a',
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <button (click)="loadPreview()">Load Preview</button>
    <app-preview [attr.data]="data"></app-preview>
  `,
})
export class FeatureAComponent {
  private injector = inject(Injector);
  public data = 'some-value';

  async loadPreview() {
    if (customElements.get('app-preview')) return;

    const featureInjector = createEnvironmentInjector([
      provideState(FEATURE_B_STORE_KEY, featureBReducers),
      provideEffects(FEATURE_B_EFFECTS),
      importProvidersFrom(TranslateModule.forChild({...})),
    ], this.injector);

    const { PreviewComponent } = await import('@myorg/feature-b');
    const PreviewElement = createCustomElement(PreviewComponent, {
      injector: featureInjector
    });
    customElements.define('app-preview', PreviewElement);
  }
}

Pros:

Cons:


The Solution: Lazy Load Service + Lazy Container

The Solution

A signal-based lazy loading service that decouples feature libraries by:

Architecture Benefits

Instead of Feature A → Feature B dependency, the pattern creates:

This inverts the dependency, moving cross-domain references to the application boundary where they belong.


Implementation

1. Component Type Enum

Define an enum in your shared models library to represent all lazy-loadable components:

// libs/shared/models/src/lib/enums/lazy-component-type.enum.ts
export enum LazyComponentType {
  FEATURE_DASHBOARD = 'FEATURE_DASHBOARD',
  FEATURE_DETAIL_VIEW = 'FEATURE_DETAIL_VIEW',
  FEATURE_PREVIEW_PANEL = 'FEATURE_PREVIEW_PANEL',
  WIDGET_CHART = 'WIDGET_CHART',
  WIDGET_TABLE = 'WIDGET_TABLE',
}

2. Dynamic Import Map

In your application, create a mapping of component types to dynamic imports:

// apps/main-app/src/app/core/lazy-load/dynamic-import-map.const.ts
import { Type } from '@angular/core';
import { LazyComponentType } from '@myorg/shared/models';

export const DYNAMIC_IMPORT_MAP: Record<LazyComponentType, () => Promise<Type<unknown>>> = {
  [LazyComponentType.FEATURE_DASHBOARD]: () =>
    import('@myorg/feature-a/components/dashboard').then(
      (m) => m.FeatureDashboardComponent,
    ),
  [LazyComponentType.FEATURE_DETAIL_VIEW]: () =>
    import('@myorg/feature-a/components/detail-view').then(
      (m) => m.FeatureDetailViewComponent,
    ),
  [LazyComponentType.FEATURE_PREVIEW_PANEL]: () =>
    import('@myorg/feature-b/components/preview-panel').then(
      (m) => m.FeaturePreviewPanelComponent,
    ),
};

Benefits:

Important: Lazy-loadable standalone components must be self-contained and import all their required dependencies directly in their imports array. This includes NgRx Store modules, translation modules, third-party libraries, and any other dependencies. The lazy container only provides the component type and props—it does not inject or provide any services or modules into the lazy component's context.

3. Application Bootstrap

Initialize the service in your application:

// apps/main-app/src/app/app.config.ts
import { ApplicationConfig, APP_INITIALIZER } from '@angular/core';
import { LazyLoadService } from '@myorg/shared/core';
import { DYNAMIC_IMPORT_MAP } from './core/lazy-load/dynamic-import-map.const';

export function initializeLazyLoadService(lazyLoadService: LazyLoadService) {
  return () => {
    lazyLoadService.setComponentImportByType(DYNAMIC_IMPORT_MAP);
  };
}

export const appConfig: ApplicationConfig = {
  providers: [
    // ...other providers
    {
      provide: APP_INITIALIZER,
      useFactory: initializeLazyLoadService,
      deps: [LazyLoadService],
      multi: true,
    },
  ],
};

Note: You can call setComponentImportByType directly during app.component.ts initialization or inside app.module.ts.

4. Lazy Load Service

Create a service in your core library that manages component loading:

// libs/shared/core/src/lib/services/lazy-load.service.ts
import { Injectable, signal, Signal, Type } from '@angular/core';
import { LazyComponentType } from '@myorg/shared/models';

@Injectable({
  providedIn: 'root',
})
export class LazyLoadService {
  private importByType: Record<LazyComponentType, () => Promise<Type<unknown>>>;
  private readonly componentTypeCache: Map<LazyComponentType, Signal<Type<unknown> | null>> = new Map();

  /**
   * Configure the dynamic import map
   */
  public setComponentImportByType(importByType: Record<LazyComponentType, () => Promise<Type<unknown>>>) {
    this.importByType = importByType;
  }

  /**
   * Lazy loads component type and caches it. Returns signal of component type.
   */
  public getComponentType(componentType: LazyComponentType): Signal<Type<unknown> | null> {
    if (!componentType) {
      return signal(null);
    }

    // Return cached signal if already loaded or loading
    if (this.componentTypeCache.has(componentType)) {
      return this.componentTypeCache.get(componentType);
    }

    // Create signal for this component type
    const componentType = signal<Type<unknown> | null>(null);

    // Start loading the component
    this.importByType?.[componentType]?.().then((type) => {
      componentType.set(type);
    });

    // Cache the signal
    this.componentTypeCache.set(componentType, componentType);
    return componentType;
  }
}

Key Service Features:

5. Lazy Container Component

Create a container component that renders lazy-loaded components:

// libs/shared/core/src/lib/components/lazy-container/lazy-container.component.ts
import { NgComponentOutlet } from '@angular/common';
import { Component, computed, inject, input } from '@angular/core';
import { LazyLoadService } from '@myorg/shared/core';
import { LazyComponentType } from '@myorg/shared/models';

type ComponentInputs = Record<string, unknown>;

@Component({
  selector: 'app-lazy-container',
  template: `
    @if (loadedComponentType()) {
      <ng-container *ngComponentOutlet="loadedComponentType(); inputs: props()" />
    }
  `,
  imports: [NgComponentOutlet],
})
export class LazyContainerComponent {
  public componentType = input<LazyComponentType>(undefined);
  public props = input<ComponentInputs>(undefined);
  private readonly lazyLoadService = inject(LazyLoadService);

  public readonly loadedComponentType = computed(() => {
    const type = this.componentType();
    return this.lazyLoadService.getComponentType(type)();
  });
}

How the container works:

Note: The lazy container component can be extended to handle loading state and output events.


Usage

In Host Component Classes

import { Component } from '@angular/core';
import { LazyContainerComponent } from '@myorg/shared/core';
import { LazyComponentType } from '@myorg/shared/models';

@Component({
  selector: 'app-main-view',
  template: `
    <app-lazy-container
      [componentType]="lazyComponentType"
      [props]="componentProps"
    />
  `,
  imports: [LazyContainerComponent],
})
export class MainViewComponent {
  public readonly lazyComponentType = LazyComponentType.FEATURE_DASHBOARD;
  public readonly componentProps = { entityId: '123' };
}

Dynamic Component Selection

@Component({
  selector: 'app-content-viewer',
  template: `
    <app-lazy-container
      [componentType]="selectedViewType()"
      [props]="viewProps()"
    />
  `,
  imports: [LazyContainerComponent],
})
export class ContentViewerComponent {
  selectedViewType = signal(LazyComponentType.FEATURE_DASHBOARD);
  viewProps = signal({ id: '123' });

  showDashboard() {
    this.selectedViewType.set(LazyComponentType.FEATURE_DASHBOARD);
    this.viewProps.set({ entityId: '123' });
  }

  showDetailView() {
    this.selectedViewType.set(LazyComponentType.FEATURE_DETAIL_VIEW);
    this.viewProps.set({ recordId: '456' });
  }
}

Library Organization

Sample Structure

libs/
  shared/
    models/                       # Shared models and enums
      src/lib/enums/
        lazy-component-type.enum.ts
    core/                         # Core services and components
      src/lib/
        services/
          lazy-load.service.ts
        components/
          lazy-container/
            lazy-container.component.ts
  feature-a/                      # Feature library
    src/lib/
      components/                 # Lazy-loadable components
        dashboard/
          dashboard.component.ts
        detail-view/
          detail-view.component.ts
  feature-b/                      # Feature library
    src/lib/
      components/                 # Lazy-loadable components
        preview-panel/
          preview-panel.component.ts

Sequence Diagram of the flow

Sequence Diagram


Benefits

Bundle Size Optimization

Performance

Maintainability

Developer Experience


Testing Strategy

Testing the Service

The LazyLoadService can be tested by mocking the component import map and verifying signal behavior.

Key Testing Patterns:

Testing the Container

The LazyContainerComponent can be tested by mocking the LazyLoadService and verifying that it correctly renders components based on signal values.

Key Testing Patterns:


Conclusion

This Lazy Loading Service & Container pattern provides a clean, maintainable solution for loading components across Nx library boundaries. It leverages Angular's modern features (Signals, Standalone Components, and NgComponentOutlet) while maintaining type safety and developer productivity.

The pattern scales well from simple use cases to complex scenarios with many lazy-loaded components across multiple libraries in an Nx monorepo.

Happy Lazy Loading!