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.
Host another app’s surface
Section titled “Host another app’s surface”A host that shows one surface and swaps it as the selection changes wants surfaceHost: call it with
the provider’s id, surface name, and a mount element, then render(params). The first render embeds
the provider’s view; later renders re-parameterise it in place — no remount. Pass onError to catch
a denied or torn-down embed.
import { Component, ElementRef, OnDestroy, inject, viewChild } from "@angular/core";import { surfaceHost, type SurfaceHost } from "@platform/sdk";import { PLATFORM } from "../shared/platform.token";
// Hosting another app's surface. surfaceHost wraps embedSurface for the common "one slot that swaps as// the selection changes" case: the first render() embeds the provider's view into our mount; later// render()s re-parameterise it in place (no remount). onError captures the easy-to-miss ready// rejection — a denied embed, or a teardown before first paint.@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 host: SurfaceHost | null = null;
show(customerEmail: string): void { this.host ??= surfaceHost(this.platform, { providerId: "billing", surfaceName: "invoice-list", mount: this.mount().nativeElement, onError: (e) => this.platform.log("warn", "invoice surface failed to embed", { code: e.code }), }); this.host.render({ customerEmail }); }
ngOnDestroy(): void { this.host?.close(); }}Provide a surface for others to host
Section titled “Provide a surface for others to host”A provider declares the surface in its contract and registers a loader for it in bootstrap. Declare
it under provides:
// in contract.tsprovides: [{ 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:
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. Call
conn.activate(el) with its root element: that observes the element so the shell can size the iframe,
then signals ready() one frame later — once content (including <ds-*> custom elements) has settled,
so the first height the shell sees is the final one. (observe() and ready() remain available if you
need to drive them separately.)
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. conn.activate(el) is the one-call bring-up — it observes the host element (so// the shell can size the iframe) and calls ready() one frame later, when content has settled. 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.activate(this.host.nativeElement); 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[]); }}See also
Section titled “See also”- Open a shell-brokered modal — a surface used as a modal body.
- Share state across your app’s frames — reflect an embedded surface’s state in its host.
Platform.embedSurface,SurfaceHost, andSurfaceConnectionin the reference.