What are dynamic components?
Dynamic components allow webpages to be generated without a predefined structure. A webpage's structure can be dynamically defined based on a data source, such as a content management system (CMS), and rendered at runtime. Content authors can pick components and change the layout of a page, for example, without developer support.
We discuss best practices for dynamically rendering pages in the content modelling chapter of our headless CMS playbook. Dynamic components offer a lot of flexibility but also come with a high degree of complexity and design choices. Read more.
Overview of dynamically rendering components in Angular
Whereas a simple map object returns a desired JSX in React, Angular requires manual handling of operations typically automated by the framework. This includes manually compiling components, attaching them to the view, and properly cleaning up after rendering dynamic components.
For instance, the class definitions of components cannot be directly passed as they are in their class files. They must be compiled correctly and then manually attached to the appropriate view to ensure they are rendered where intended. In addition, module-based and standalone components have different compilation processes, adding another layer of complexity to the process.
At a high level, our approach to dynamically loading components in React can be extended to Angular with a twist. To build a dynamic (or non-templated) page, you need to:
- Load and pass content data (eg from your CMS) to front-end components;
- Map your content models to your front-end components; and
- Create Angular components and attach them to view.
You can access all the code you'll need to render dynamic components in Angular in our demo app. What we’ll be discussing in depth here are contained in these key files:
Loading and passing content data
To connect content data to front-end components, we typically retrieve JSON data from a CMS (or a REST endpoint). This can be done either within a component's ngOnInit lifecycle method or by leveraging Angular's route resolver functions, for example. By using a route resolver, the necessary page data is fetched before navigating to the new route.
In a recent webinar, we demonstrated how to build Angular applications using Analog for static site generation (SSG) and server-side rendering (SSR). We’ll be releasing a full working codebase for dynamically rendering components in Angular applications with SSG and SSR soon: subscribe to our headless CMS series to get our latest guides and code repositories.
In the example application we created for this article, we utilize route resolvers:
// routeDataResolver.ts
export const routeDataResolver: ResolveFn<any> = (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
) => {
const httpClient = inject(HttpClient);
const router = inject(Router);
// YOUR_URL could be a call to a CMS or a REST endpoint
return httpClient.get([YOUR_URL]).pipe(
catchError((_) => {
// Redirect to: /not-found
router.navigateByUrl('/not-found');
return EMPTY;
})
);
};
Take a look at the example below where we're showing a home page component. When we land on the home page, we're loading data from the CMS. If successful (ie page isn't empty), we show the data. Otherwise, we redirect to a 404 page.
// app-routing.module.ts
const routes: Routes = [
{
path: '',
redirectTo: 'home',
pathMatch: 'full',
},
{
// home page
path: 'home',
component: HomeComponent,
resolve: { pageData: routeDataResolver },
},
{
path: 'post/:id',
component: PostComponent,
resolve: { pageData: routeDataResolver },
},
{
path: '**',
component: NotFoundComponent,
},
];
Now that we have the data for the home page, we can serve the component:
// home.component.ts
@Component({
selector: 'app-home',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<ng-container *ngIf="pageData$ | async as pageData">
// Pass data to components
<app-render-template [components]="pageData.children" />
</ng-container>
`,
})
export class HomeComponent {
// You can access page data on the pageData variable
pageData$ = this.activatedRoute.data.pipe(map(({ pageData }) => pageData));
constructor(private activatedRoute: ActivatedRoute) {}
}
In our example, any pages that need to be dynamically loaded need to be predefined and configured beforehand. For greater flexibility to your application’s rendering capabilities, use dynamic page routing.
Dynamic page routing accommodates changes in page structure and content without requiring predefined routes. This enables content authors to enter and alter routes in the CMS. During the application bootstrap process, merge static routes (ie predefined routes) with additional routes dynamically loaded from the CMS. Then, map these dynamic routes to a basic template that includes an app-render-template component.
Mapping components
You’ll need to load all the components you need to dynamically build a given page.
Below, we have a mapper that will lazy load components.
// dynamic-component-manifest.ts
const dynamicComponentMap: ComponentMap = {
// Component names must match content model names in the JSON data
pageContainer: {
// Lazy loading components
loadComponent: () =>
import('../../dynamic-components/page-container').then(
(m) => m.PageContainerModule as Type<DynamicModule>
),
},
pageSection: {
loadComponent: () =>
import('../../dynamic-components/page-section').then(
(m) => m.PageSectionModule as Type<DynamicModule>
),
},
postPreview: {
loadComponent: () =>
import('../../dynamic-components/post-preview').then(
(m) => m.PostPreviewComponent as Type<DynamicComponent>
),
},
};
The names of your components (in your manifest object) must match the names of your content models (in your JSON response). Here’s an example snippet of a JSON response:
// Example JSON response:
{
"header": "Dynamic Renderer",
"children": [// Content model maps to the component
{
"name": "pageContainer", // This name must match the component name
"componentData": {
"children": [
{
"name": "pageSection",
"componentData": { // Map to properties of the component
"children": [
{
"name": "postPreview",
"componentData": {
"title": "When a tree falls in the forest what happens?",
"subtitle": "The forest critters loose a long time friend but gain a new one",
"shortName": "trees",
"author": "Lars Hoff",
"dateAsString": "May 31, 2023"
}
}
...
The names of your content models and components must match. This is critical for the component mapper to work and pass all the relevant properties.
Below, we check to see if the name passed matches a valid component and return the matched component.
// render-template.component.ts
ngAfterViewInit() {
const loadedComponentModules = this.components
// Check if component exists
.filter((componentData) =>
this.dynamicComponentsService.checkComponentMap(componentData)
)
// Map to component class/module
.map(async (componentTemplate) => {
const itemRef = await this.dynamicComponentsService.loadComponentInfo(
componentTemplate.name
);
return { renderItemRef: itemRef };
});
// Return the component
this.renderComponents(loadedComponentModules);
}
Next, we load the matched component based on whether it is a module-based or standalone component.
// dynamic-component.service.ts
async loadComponentInfo(name: string) {
const loadedComponent = await dynamicComponentMap
.get(name)!
.loadComponent();
// If the component and content models don’t match, return an error
if (!loadedComponent) {
throw new Error(`Component not found for: ${name};`);
}
if (isModuleDefinition(loadedComponent)) {
// Module-based components require additional processing
return createNgModule<DynamicModule>(loadedComponent, this.injector);
} else {
// Standalone components can be returned directly
return loadedComponent;
}
}
Creating Angular components
To create dynamic components in Angular that you can render, we use dynamicComponentsService.
First, we pass the dynamic components to be rendered.
// render-template.component.ts
template: ` <ng-template #container></ng-template> `,
...
@ViewChild('container', { read: ViewContainerRef })
container: ViewContainerRef;
...
async renderComponents(items: Promise<LoadedRenderItems>[]) {
const allSettledItems = await Promise.allSettled(items);
for (let item of allSettledItems) {
// Pass each dynamic component to be rendered
if (isFulfilled(item)) {
const newComponent = this.dynamicComponentsService.createComponent(
this.container,
item.value.renderItemRef
);
if (newComponent) {
this.componentRefs.push(newComponent);
}
} else {
console.error(item.reason);
}
}
// Check for changes to update the view accordingly
this.cdr.markForCheck();
}
Second, we create the components and attach them to the view container.
// dynamic-component.service.ts
createComponent(
container: ViewContainerRef,
renderItem: LoadedRenderItem
) {
let componentRef: ComponentRef<any>;
// Module-based components:
if (renderItem instanceof NgModuleRef) {
componentRef = container.createComponent(renderItem.instance.entry, {
ngModuleRef: renderItem,
});
// Standalone components:
} else {
componentRef = container.createComponent(renderItem);
}
// Attach component to view and render to DOM
container.insert(componentRef.hostView);
return componentRef;
}
It’s a good idea to clear created components before loading new ones. Our third step destroys our dynamic components now that they’ve been rendered.
// render-template.component.ts
ngOnDestroy() {
// Clean up created components
this.componentRefs.forEach((ref) => ref.destroy());
if (this.container) {
this.container.clear();
}
}
// Before loading new components, clear existing components
ngAfterViewInit() {
if (!this.container || !this.components || this.components.length === 0) {
return;
}
this.componentRefs.forEach((ref) => ref.destroy());
...
}
Passing properties
A component’s properties correspond to the fields where authors input content, eg text, image. After getting the right component, you’ll need to pass the relevant props.
To parse and return the appropriate content data, we add this additional step before rendering the component.
// render-template.component.ts -> ngAfterViewInit
const itemRef = await this.dynamicComponentsService.loadComponentInfo(
componentTemplate.name
);
// Return content data with the component
return { renderItemRef: itemRef, componentTemplate };
Similarly, we’ll pass the content data when creating the component.
// render-template.component.ts -> renderComponents
const newComponent = this.dynamicComponentsService.createComponent(
this.container,
// Pass data
item.value.componentTemplate,
item.value.renderItemRef
);
Now, your content data will be included in your dynamic component.
// dynamic-component.service.ts
createComponent(
container: ViewContainerRef,
componentTemplate: ComponentTemplate,
renderItem: LoadedRenderItem
) {
let componentRef: ComponentRef<any>;
let resolverData: any;
// Check if module-based component
if (renderItem instanceof NgModuleRef) {
// Pass content data to module-based component
resolverData =
renderItem.instance.componentDataResolver &&
renderItem.instance.componentDataResolver(
componentTemplate.componentData || {}
);
componentRef = container.createComponent(renderItem.instance.entry, {
ngModuleRef: renderItem,
});
} else {
componentRef = container.createComponent(renderItem);
// Pass content data to standalone component
resolverData = componentRef.instance.componentDataResolver(
componentTemplate.componentData || {}
);
}
if (resolverData) {
// Apply content data as properties
Object.keys(resolverData).forEach(
(key) => (componentRef.instance[key] = resolverData[key])
);
}
container.insert(componentRef.hostView);
return componentRef;
}
Module-based components
Let’s look at PageSectionComponent, a module-based component. We'll map content data fields (eg section heading, children) to the relevant component properties.
// page-section.component.ts
@Component({
selector: 'app-page-section',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'd-block col-lg-8 col-md-10 mx-auto',
},
template: `
<h2 *ngIf="sectionHeading" class="section-heading">{{ sectionHeading }}</h2>
// Facilitates nested rendering of child components
<app-render-template [components]="children" />
`,
})
export class PageSectionComponent implements OnInit {
sectionHeading: string;
children: ComponentTemplate[];
constructor() {}
ngOnInit() {}
}
// Returns an object that maps content data to component properties
export const componentDataResolver = (data: ComponentData) => {
return {
sectionHeading: data.sectionHeading,
children: data.children,
};
};
We designate PageSectionComponent as the starting point for rendering PageSectionModule.
// page-section.module.ts
@NgModule({
imports: [DynamicTemplatesModule, CommonModule],
exports: [PageSectionComponent],
declarations: [PageSectionComponent],
providers: [],
})
export class PageSectionModule implements DynamicModule {
// Set PageSectionComponent as the starting point
entry = PageSectionComponent;
componentDataResolver = componentDataResolver;
}
Note that the resolver function componentDataResolver is defined outside the component in module-based components.
Standalone components
A standalone component includes a componentDataResolver method within the component class itself.
In our example below, it maps the text field of ComponentData to the text property of TextContainerComponent.
// text-container.component.ts
@Component({
selector: 'app-text-container',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
standalone: true,
template: ` <p *ngFor="let item of text">{{ item }}</p> `,
})
export class TextContainerComponent implements DynamicComponent {
text: string[];
constructor() {}
// Maps content data to component properties
componentDataResolver(data: ComponentData) {
return {
text: data.text,
};
}
}
Conclusion
Dynamic components offer significant benefits for businesses and content authors, particularly when combined with a CMS. By leveraging dynamic rendering, authors can harness the power of reusable building blocks to create webpages more efficiently and consistently. This approach allows for quick iterations in design and customization, eliminating the reliance on developers and the need for time-consuming build and deploy cycles.
Taken together, adopting dynamic components accelerates the delivery of the final product to users, enhancing the overall user experience and driving business success.
Subscribe to follow our headless CMS series and be the first to hear about upcoming events and content.