How to add a drag-and-drop report designer to an Angular application
The dragging is the easy part. This is about the parts that decide whether an embedded designer is a pleasure or a permanent tax in an Angular codebase: change detection, routing, bundle size, server-side rendering and where the PDF is produced.
First: should you build it?
If you have landed here with @angular/cdk/drag-drop open in another tab, this section is the most
useful thing on the page. The CDK is good, and a drop-list prototype that moves blocks around a page takes an
afternoon and demos beautifully.
Then you need, in roughly this order:
- A document model that can be stored, versioned and rendered — not a DOM tree, a real serialisable one.
- A binding layer so a non-developer can say “this text is the client's name”, with a picker and type checking and without letting anybody write JavaScript.
- Undo and redo over that model, which is where most homegrown editors stop being fun.
- Validation, because users can now save layouts that cannot render, and you have to catch that before a customer does.
- A paginator — and this is the one people never cost — that decides page breaks, keeps a heading with its paragraph, repeats a table header, and agrees with whatever draws your PDF.
The dragging is about a tenth of it. Estimate the paginator first: it is invisible when it works, and it is the part that makes a preview trustworthy.
Building all of it is a reasonable decision when documents are the product you sell. If they are not, the rest of this guide is what integrating a finished one looks like.
1. Install and route
Angular 21 or later — that is the version the package's Ivy declarations are emitted against, and the floor because everything below it has reached end of life or is about to.
pnpm add @broadpaper/core @broadpaper/blocks @broadpaper/renderer \
@broadpaper/editor @broadpaper/angular @broadpaper/forme Put the designer behind a lazy route before you write anything else. It and the PDF engine are the heaviest things in the application, and the people who only ever read documents should never download either.
import type { Routes } from "@angular/router";
export const routes: Routes = [
{ path: "reports", loadComponent: () => import("./reports/list.component").then((m) => m.ListComponent) },
{
// The editor and the PDF engine live behind this route and nowhere else.
path: "reports/:kind/design",
loadComponent: () => import("./reports/designer.component").then((m) => m.DesignerComponent),
resolve: { schema: schemaResolver }
}
]; A resolver is a good fit for the schema. It is per-tenant, per-user data that has to exist before the editor mounts, and resolving it makes “no schema yet” a routing state rather than a template condition.
2. The component
Standalone, OnPush, signals, and one binding per thing the editor needs. Inputs mirror the
framework-neutral options object, so everything in the API reference is available as a binding.
import { ChangeDetectionStrategy, Component, inject, signal } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { ReportDesignerComponent } from "@broadpaper/angular";
import type { DataSource, Issue, ReportTemplate, Theme } from "@broadpaper/core";
import { TemplateService } from "./template.service";
@Component({
selector: "app-designer",
standalone: true,
imports: [ReportDesignerComponent],
// OnPush is safe here precisely because the editor runs outside the zone:
// nothing inside the canvas asks this component to re-render.
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (schema(); as dataSources) {
<bp-report-designer
[template]="template()"
[dataSources]="dataSources"
[sampleData]="sample()"
[theme]="brand()"
[features]="{ expressions: false, jsonView: false, whiteLabel: true }"
[onSave]="save"
saveDescription="Saved to your workspace."
(templateChange)="draft.set($event)"
(validate)="issues.set($event)"
style="height: 100%"
/>
}
`,
host: { style: "display:block;height:calc(100vh - 56px)" }
})
export class DesignerComponent {
private readonly route = inject(ActivatedRoute);
private readonly templates = inject(TemplateService);
readonly schema = signal<DataSource[] | null>(this.route.snapshot.data["schema"]);
readonly template = signal<ReportTemplate | undefined>(undefined);
readonly draft = signal<ReportTemplate | undefined>(undefined);
readonly issues = signal<Issue[]>([]);
readonly sample = signal(this.route.snapshot.data["sample"]);
readonly brand = signal<Theme>(this.route.snapshot.data["brand"]);
// An arrow property. The component hands this reference straight to the
// editor, so a class method would lose `this` and fail on first save.
save = async (t: ReportTemplate): Promise<void> => {
await this.templates.save(this.route.snapshot.params["kind"], t);
};
} Three things in there are worth pointing at.
-
[onSave]is an arrow property. The component passes the reference straight to the editor, so a class method losesthisand fails the first time somebody presses Save — in production, on a document that mattered. This is the single most common mistake when wiring it up. - The host has a height. The editor fills its container. A host with no height collapses to nothing, and the resulting bug report is “the designer doesn't appear”.
-
[features]is how you choose the audience. Turning off the expression editor and the raw JSON view is the difference between a tool an operations manager will use and one they will close. It is a user interface decision, not a permission — enforce permissions at your API.
3. Change detection
This is the question that decides whether an embedded editor is tolerable in an Angular application, and it is worth understanding rather than hoping about.
A report canvas is a drag surface. Dragging a block fires pointer events at frame rate; a viewer scrolling a forty-page document fires a page-position event per frame. If those events run inside the zone, your application runs change detection dozens of times a second while somebody moves a heading — and every performance complaint that follows will be attributed to your application, not to the editor.
BroadPaper's components create the editor inside NgZone.runOutsideAngular and step back in with
zone.run only to emit an output. So everything you subscribe to arrives in the zone and nothing else
does, which is why OnPush on the host above is safe.
It also means zone.js and zoneless applications are both fine. If you are evaluating a different component, ask this early: an editor that assumes zone.js is an editor that will pin you to it.
4. Saving and loading
An ordinary service. The editor awaits whatever onSave returns and surfaces a rejection as “Save
failed” with your message, so let the error through rather than catching it into a toast of your own.
import { HttpClient } from "@angular/common/http";
import { Injectable, inject } from "@angular/core";
import { firstValueFrom } from "rxjs";
import type { ReportTemplate } from "@broadpaper/core";
@Injectable({ providedIn: "root" })
export class TemplateService {
private readonly http = inject(HttpClient);
// The editor awaits this and shows "Save failed" with your message if it
// rejects, so let the error through rather than swallowing it.
save(kind: string, template: ReportTemplate): Promise<void> {
return firstValueFrom(this.http.put<void>(`/api/templates/${kind}`, template));
}
load(kind: string): Promise<ReportTemplate> {
return firstValueFrom(this.http.get<ReportTemplate>(`/api/templates/${kind}`));
}
}
On the server, validate before storing. validateTemplate checks every binding against the schema
without needing any data at all, which is what stops a design written against last quarter's fields becoming a
blank space on a customer's document. The
architecture guide covers the storage and versioning side in
full — it is framework-independent, so all of it applies here.
5. Server-side rendering
The designer mounts into a DOM element, so it cannot render on the server. This is the same constraint as any
component that owns an element rather than describing one, and Angular has had the tools for it since
afterNextRender arrived.
import { Component, afterNextRender, signal } from "@angular/core";
@Component({ /* … */ template: `@if (ready()) { <bp-report-designer … /> }` })
export class DesignerComponent {
// False on the server and on the first client render; true once the
// browser has a DOM to mount into. afterNextRender does not run during
// server-side rendering at all, which is exactly the behaviour wanted.
readonly ready = signal(false);
constructor() {
afterNextRender(() => this.ready.set(true));
}
} In practice the cleanest answer is usually routing: a designer route is behind a sign-in, it has no search value, and nothing about it benefits from being pre-rendered. The read-only viewer is the interesting case — a shared report page might legitimately want to be server-rendered — and there the right shape is to render your own shell and hydrate the viewer on the client.
6. Build and bundle size
Angular's builder is the easy one here. It wants the PDF engine's worker build, and @broadpaper/forme
resolves to it, so there is nothing to add to angular.json — where webpack hosts need
experiments.asyncWebAssembly and Next.js needs that plus care about server rendering.
What you will meet is a budget warning. The engine is around seven megabytes of WebAssembly, and it belongs in the
lazy chunk with the designer rather than the initial one. Keep the initial budget tight — that is the
number that protects your start-up time — and accept that the designer's own chunk is large, because it is
downloaded by the few people who open it.
{
"budgets": [
{ "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
{
"type": "anyComponentStyle",
"maximumWarning": "4kB"
}
]
} If the engine ends up in your initial bundle, something has imported it eagerly. The usual culprit is a barrel file re-exporting a render helper from somewhere shared.
7. Producing the PDF
Two paths, and most Angular applications end up with both.
- In the tab. The WebAssembly engine draws a vector PDF in the browser that designed the report. No server, no headless Chromium, and nothing leaves the device — which is the right answer for “show me before I send it”.
- From the back end.
@broadpaper/serverputs the same engine behindPOST /renderwith a bounded queue, a bearer token and a Dockerfile;BroadPaper.Clienton NuGet targets .NET 8 and .NET 10 and calls it. This is where scheduled runs, emailed documents and anything audited belong, because the artefact of record should be produced by code you control.
The page breaks are decided by BroadPaper's own paginator before either backend draws anything, so the two agree. That is what makes the in-browser preview worth having: it is not an approximation of the file, it is the same pagination.
One deployment note if you render server-side at volume: the engine is synchronous WebAssembly, so a long document holds the thread it is on. Render on worker threads rather than on the thread answering requests, or a four thousand row document will stop everything else while it draws.
The Angular page has the component reference and the .NET path in more detail, and the integration guide has every input and output with compiled examples.
Questions
- Can I build a report designer with the Angular CDK?
- You can build the dragging with
@angular/cdk/drag-drop, and it is good at it. The dragging is perhaps a tenth of the work: the rest is a document model, a binding layer with type checking, validation, versioning, and a paginator whose page breaks agree with whatever renders your PDF. Start there when you estimate, not with the drop lists. - Does an embedded designer break zoneless Angular?
- It should not, and BroadPaper's does not. The components create the editor inside
NgZone.runOutsideAngularand re-enter the zone only to emit outputs, so they work the same whether your application runs with zone.js or without it. If you are evaluating another component, this is a question worth asking early — an editor that assumes zone.js is an editor that will pin your migration. - How do I lazy-load the designer?
- A
loadComponentroute. The designer and the PDF engine together are the heaviest thing in the application, and most users never open them, so keeping both out of the initial bundle is the single highest-value thing you can do for start-up time. - Does it work with server-side rendering?
- The application does; the designer component does not render on the server, because it mounts into a DOM element. Guard it with
afterNextRenderor anisPlatformBrowsercheck, or put it on a route your SSR configuration renders on the client. That is the same rule as for any DOM-owning component. - Will the WebAssembly PDF engine survive the Angular builder?
- Yes. Angular's builder wants the engine's worker build and
@broadpaper/formeresolves to it, so there is nothing to add toangular.json— unlike webpack, which needsexperiments.asyncWebAssembly. You will want to raise a bundle budget for the lazy chunk, which is a one-line change and worth doing deliberately. - Can the PDF be produced by our .NET back end instead?
- Yes.
@broadpaper/serverputs the engine behind HTTP with a queue and a bearer token, andBroadPaper.Clienton NuGet targets .NET 8 and .NET 10. Your C# service posts the stored template and the record; the same paginator decides the breaks, so the file matches what the browser previewed.
Open the editor these components mount.
Framework-neutral inside, which is why one product serves Angular and React. No account, nothing uploaded, and a real vector PDF at the end.