Skip to content

Provide and host an embedded surface

A surface is a piece of one app’s UI that another app (or the shell dashboard) can host inside itself. The shell brokers a nested iframe; you write two ordinary components — one that embeds, one that is embedded.

A host app contains a nested embedded surface; the provider app's surface is piped into it, brokered by the shell.

Call platform.embedSurface with the provider’s id and surface name and a mount element. You get back a SurfaceHandle. The first call embeds; later calls re-parameterise in place via update() — no remount.

invoice-panel.ts
import { Component, ElementRef, OnDestroy, inject, viewChild } from "@angular/core";
import type { SurfaceHandle } from "@platform/sdk";
import { PLATFORM } from "../shared/platform.token";
// Hosting another app's surface: embedSurface mounts the provider's view into our DOM and returns a
// handle. The first call embeds; later calls re-parameterise in place via update() (no remount).
@Component({
selector: "invoice-panel",
template: `<div #mount></div>`,
})
export class InvoicePanel implements OnDestroy {
private readonly mount = viewChild.required<ElementRef<HTMLElement>>("mount");
private readonly platform = inject(PLATFORM);
private surface: SurfaceHandle | null = null;
show(customerEmail: string): void {
if (!this.surface) {
this.surface = this.platform.embedSurface({
providerId: "billing",
surfaceName: "invoice-list",
params: { customerEmail },
mount: this.mount().nativeElement,
});
// ready rejects if the provider denies the embed or the host tears it down before it renders.
this.surface.ready.catch((e: { code?: string }) =>
this.platform.log("warn", "invoice surface failed to embed", { code: e?.code }),
);
} else {
this.surface.update({ customerEmail });
}
}
ngOnDestroy(): void {
this.surface?.close();
}
}

A provider declares the surface in its contract and registers a loader for it in bootstrap. Declare it under provides:

// in contract.ts
provides: [{ kind: "surface", name: "invoice-list", contexts: ["embed", "dashboard"] }],

Register the loader in bootstrap and mount it with the SurfaceConnection injected through a token. bootstrap runs only the path taken — a top-level load runs mountApp, an embedded-surface load runs mountSurface:

src/main.ts
import { reflectComponentType, type Type } from "@angular/core";
import { bootstrapApplication } from "@angular/platform-browser";
import { bootstrap } from "@platform/sdk";
import { standalonePlatform } from "@platform/dev-harness";
import { PLATFORM } from "../shared/platform.token";
import { SURFACE_CONN } from "../shared/surface.token";
import { contract } from "../quickstart/contract";
// An app that PROVIDES a surface registers a loader for it under the surface's contract name. bootstrap
// invokes only the path taken: a top-level load runs mountApp; an embedded-surface load runs
// mountSurface with the connected SurfaceConnection injected through SURFACE_CONN.
void bootstrap<Type<unknown>, Type<unknown>>({
contract,
app: () => import("../quickstart/app").then((m) => m.App),
surfaces: {
"invoice-list": () => import("./embed-surface.provider").then((m) => m.InvoiceListSurface),
},
fallback: () => standalonePlatform({ appId: "billing" }),
mountApp: (app, platform) =>
bootstrapApplication(app, { providers: [{ provide: PLATFORM, useValue: platform }] }),
mountSurface: (surface, conn) => {
const selector = reflectComponentType(surface)?.selector;
if (!selector) {
throw new Error(`surface "${conn.surfaceName}" is not a component`);
}
document.body.appendChild(document.createElement(selector));
return bootstrapApplication(surface, {
providers: [{ provide: SURFACE_CONN, useValue: conn }],
});
},
}).catch((err) => console.error(err));

The surface component reads its SurfaceConnection instead of the top-level Platform. It must observe() its host element so the shell can size the iframe, and call ready() once rendered:

invoice-list-surface.ts
import { Component, ElementRef, OnInit, inject, signal } from "@angular/core";
import { SURFACE_CONN } from "../shared/surface.token";
interface Invoice {
id: string;
total: number;
}
// The provider side of a surface: a normal component that reads its SurfaceConnection instead of the
// top-level Platform. It must observe() its host element (so the shell can size the iframe) and call
// ready() once rendered. onParams re-runs it when the host re-parameterises; onRefresh handles a
// dashboard refresh.
@Component({
selector: "invoice-list-surface",
template: `
<ul>
@for (inv of invoices(); track inv.id) {
<li>{{ inv.id }} — {{ inv.total }}</li>
}
</ul>
`,
})
export class InvoiceListSurface implements OnInit {
private readonly conn = inject(SURFACE_CONN);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
protected readonly invoices = signal<Invoice[]>([]);
ngOnInit(): void {
this.conn.observe(this.host.nativeElement);
this.conn.ready();
this.conn.onParams((params) => void this.load(params as { customerEmail?: string }));
this.conn.onRefresh(() => void this.load(this.conn.params as { customerEmail?: string }));
void this.load(this.conn.params as { customerEmail?: string });
}
private async load(params: { customerEmail?: string }): Promise<void> {
const res = await fetch(
`/api/invoices?customer=${encodeURIComponent(params.customerEmail ?? "")}`,
{
credentials: "same-origin",
},
);
this.invoices.set((await res.json()) as Invoice[]);
}
}