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”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.
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(); }}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. It must
observe() its host element so the shell can size the iframe, and call ready() once rendered:
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[]); }}See also
Section titled “See also”- Open a shell-brokered modal — a surface used as a modal body.
Platform.embedSurfaceandSurfaceConnectionin the reference.