An <ng-template> is a piece of template that does not render by itself: it is a “recipe” that you can instantiate at any time, as many times as you like, with different data. Combined with ngTemplateOutlet, it lets you build components that handle behaviour (selection, paging, virtualisation) and leave the look to the parent — the equivalent of “render props” in React.
The basics: ng-template + ngTemplateOutlet + context
<!-- An <ng-template> does NOT render by itself: it is a “recipe” you instantiate when you want -->
<ng-template #greeting let-name let-time="time">
<p>Good {{ time }}, {{ name }}!</p>
</ng-template>
<!-- instantiate it with ngTemplateOutlet, giving it a context -->
<ng-container
[ngTemplateOutlet]="greeting"
[ngTemplateOutletContext]="{ $implicit: 'Ana', time: 'morning' }"
/>
<!-- $implicit → let-name ; time → let-time="time" -->A component with a customisable template
// A list that handles selection and navigation, but lets the PARENT decide how a row looks.
@Component({
selector: 'app-select-list',
imports: [NgTemplateOutlet],
template: `
@for (item of items(); track $index; let i = $index) {
<li [class.sel]="i === selectedIndex()" (click)="selectedIndex.set(i)">
@if (itemTpl(); as tpl) {
<ng-container
[ngTemplateOutlet]="tpl"
[ngTemplateOutletContext]="{ $implicit: item, index: i, selected: i === selectedIndex() }"
/>
} @else {
{{ item }}
}
</li>
}
`,
})
export class SelectList<T> {
readonly items = input.required<T[]>();
protected readonly selectedIndex = signal(0);
protected readonly itemTpl = contentChild(ItemTemplate, { read: TemplateRef });
}- Apples
- Pears
- Plums
- AnaTech lead#1 selected
- MihaiFrontend
- IoanaDesign
Typed context with ngTemplateContextGuard
Without help, the let-* variables are any — you lose autocompletion and type checking. A small directive on the ng-template, with a static ngTemplateContextGuard method, solves this. It is exactly the technique used by NgFor, the CDK Table and most UI libraries.
// Without help, the let-* variables of an <ng-template> are `any`.
// A directive with ngTemplateContextGuard gives them the correct type.
interface ItemContext<T> {
$implicit: T;
index: number;
selected: boolean;
}
@Directive({ selector: 'ng-template[appItem]' })
export class ItemTemplate<T> {
// input used only so TypeScript can infer T from [appItem]="members()"
readonly appItem = input.required<T[]>();
static ngTemplateContextGuard<T>(_dir: ItemTemplate<T>, ctx: unknown): ctx is ItemContext<T> {
return true;
}
}Template as input
// Alternatively: the template as an INPUT (when you don’t want projection)
export class DataGrid<T> {
readonly rows = input.required<T[]>();
readonly rowTemplate = input<TemplateRef<{ $implicit: T }>>();
}
// <ng-template #row let-user>{{ user.name }}</ng-template>
// <app-data-grid [rows]="users()" [rowTemplate]="row" />The injector used by the outlet (v21.2)
// v21.2: ngTemplateOutlet can use the injector of the place where it is RENDERED (the 'outlet' option)
<ng-container
[ngTemplateOutlet]="tpl"
[ngTemplateOutletInjector]="'outlet'"
/>
// By default, a template “sees” the injector of where it was DECLARED.
// With 'outlet' it sees the injector of the component that RENDERS it — useful for templates
// that inject a context provided by the container (e.g. a selection service).ng-container: grouping without an element
<ng-container> does not appear in the DOM. You use it when you need a spot in the template but do not want an extra <div>: as a host for ngTemplateOutlet, to group a few elements for a directive, or where an extra element would break the HTML (inside <tr>, <ul>, <select>) or a flex or grid layout.
<!-- an extra element inside <select> would be invalid HTML; ng-container does not appear in the DOM -->
<select>
@for (group of groups(); track group.id) {
<ng-container>
<option disabled>{{ group.name }}</option>
@for (o of group.options; track o.id) {
<option [value]="o.id">{{ o.label }}</option>
}
</ng-container>
}
</select>
<!-- a host for a template, without a <div> -->
<ng-container *ngTemplateOutlet="rowTpl(); context: { $implicit: row }" />A structural directive of your own
The asterisk in *appIfRole is just shorthand: Angular wraps the element in an <ng-template> and gives the directive a TemplateRef. The directive decides on its own when to create the view (createEmbeddedView) and when to remove it (clear).
@Directive({ selector: '[appIfRole]' })
export class IfRole {
private readonly tpl = inject(TemplateRef);
private readonly vcr = inject(ViewContainerRef);
private readonly auth = inject(AuthStore);
readonly appIfRole = input.required<Role>();
constructor() {
effect(() => {
this.vcr.clear();
if (this.auth.hasRole(this.appIfRole())) {
this.vcr.createEmbeddedView(this.tpl); // creates the content only when the role matches
}
});
}
}
<!-- usage, with the asterisk -->
<button *appIfRole="'admin'">Delete</button>
<!-- what Angular turns the asterisk into -->
<ng-template [appIfRole]="'admin'"><button>Delete</button></ng-template>In apps you rarely need one: @if covers almost everything. It pays off when the same condition, depending on a service (role, feature flag, language), repeats across dozens of templates.
When YES, when NO
- Reusable generic components: list, table, select, autocomplete, tree.
- Content that must be created lazily or several times (see the trap in Content projection).
- “Optional” slots with a fallback: if the parent gives a template, you use it; otherwise the default display.
- Static content, displayed once →
<ng-content>is simpler. - You have a single place of use → write the markup directly, without abstraction.
- Small variations in look → an input (
variant,size) is clearer.
Common pitfalls
- A new context on every render:
[ngTemplateOutletContext]="{ ... }"creates a new object; Angular handles it efficiently, but don’t put heavy calculations in it. let-xwithout a value reads$implicit;let-x="key"readscontext.key.- A template declared in one place, rendered in another: use the injector from the declaration (see the
'outlet'option from v21.2).
Cheat sheet
<ng-template #tpl let-item let-i="index">…</ng-template> <!-- a fragment that does not render by itself -->
<ng-container *ngTemplateOutlet="tpl; context: { $implicit: x, index: 0 }" />
<ng-container [ngTemplateOutlet]="tpl" [ngTemplateOutletContext]="ctx" />
<ng-container>…</ng-container> <!-- grouping without an element in the DOM -->
readonly tpl = contentChild<TemplateRef<Ctx>>('row'); <!-- template received through projection -->
readonly tpl = input<TemplateRef<Ctx>>(); <!-- template received as an input -->
inject(TemplateRef) inject(ViewContainerRef) <!-- a structural directive of your own -->
vcr.createEmbeddedView(tpl, ctx) vcr.clear()