Skip to main content

Building Configuration UIs in Angular, Vue, and Svelte

This guide shows how to build a marketplace and headless configuration experience with @prismatic-io/solis-core, the framework-agnostic package of Solis, Prismatic's headless SDK. solis-core is plain TypeScript, so it works in Angular, Vue, Svelte, or an app with no framework, but it doesn't know about your framework's rendering cycle. Most of the work is connecting its client to your components' lifecycle and turning its live data into your framework's reactive state.

The examples come from the Headless Marketplace page of the Angular example app, with equivalent Vue and Svelte code where the framework changes the approach. If your app uses React, use Solis React instead; its hooks handle the lifecycle for you.

Install the package​

npm install @prismatic-io/solis-core

solis-core uses the DOM and window, so it runs only in the browser. If you use server-side rendering, such as Angular SSR, Nuxt, or SvelteKit, create the client only from code that runs in the browser. For more on connecting, see Installing the headless SDK.

Get a token​

solis-core needs an embedded user JWT, signed by your backend as described in Embedded Authentication. Fetch the token and refresh it in one app-wide place, such as an Angular service, a Vue composable, or a Svelte module, so every page shares it. Authenticating in your frontend framework has a token store for each framework. If your app uses only Solis, drop that store's prismatic.init and prismatic.authenticate calls: solis-core takes the token directly.

The examples below read the token from that store as auth.token and your Prismatic URL as auth.prismaticUrl.

How solis-core represents data​

createClient connects to Prismatic through a hidden iframe and returns a client. The client hands out stubs: references to Prismatic objects such as a marketplace integration, an instance, a configuration, or a connection.

  • Each stub's state() resolves to a ReadableStream that emits the object's current data, and again whenever it changes.
  • Each stub has methods for its actions, such as createInstance, save, and deploy.

The data and actions are the same ones the React hooks expose. For what each object contains, see Custom Marketplace UI and Building configuration UIs with Solis React.

Tie the client to the page​

Create the client when the token is available, and dispose it when the user leaves the page. client.dispose() removes the hidden iframe and releases every stub the client handed out. Two details make this safe in a framework:

  • The page can close while createClient is still connecting. Track whether the component was destroyed, and dispose the new client if it was.
  • A refreshed token goes to the existing client. Call client.authenticate(newToken) instead of creating a new client. For the same user, stubs you already hold stay valid. Make new requests through client.authenticated, which always holds the handle for the newest token.
src/app/headless-marketplace/headless-marketplace.ts (excerpt)
export class HeadlessMarketplace {
private readonly auth = inject(PrismaticAuth);
private client?: Client;
private destroyed = false;

constructor() {
effect(() => {
const token = this.auth.token();
if (!token) return;
// A refreshed token for the same user keeps every stub valid.
if (this.client) this.client.authenticate(token);
else this.load(token);
});
inject(DestroyRef).onDestroy(() => {
this.destroyed = true;
this.client?.dispose();
});
}

private async load(jwt: string) {
const client = await createClient({
prismaticUrl: this.auth.prismaticUrl(),
jwt,
});
if (this.destroyed) return client.dispose();
this.client = client;
// List the marketplace. See the next section.
}
}

Keep client in a plain variable, not in reactive state: it's a connection, not data to render.

List the marketplace​

client.authenticated.marketplace.list() returns the marketplace's integrations as stubs. It accepts the same filters as the React hook, listed in Custom Marketplace UI. Read each stub's state() stream in a loop and write every value into your framework's state, so a card updates on its own after the customer deploys or removes an instance:

Copy each integration's state into a list (Angular)
interface Listing {
stub: IntegrationStub;
state: MarketplaceIntegrationState;
}

readonly listings = signal<Listing[]>([]);

private async load(jwt: string) {
// ...create the client, as above
const page = await client.authenticated!.marketplace.list({
includeActiveIntegrations: true,
});
for (const stub of page.integrations as unknown as IntegrationStub[]) {
// The stream ends with an error when the client is disposed.
this.watch(stub).catch(() => {});
}
}

/** Each listing's `state()` stream emits now, and again after every change. */
private async watch(stub: IntegrationStub) {
const reader = (await stub.state()).getReader();
for (let next = await reader.read(); !next.done; next = await reader.read()) {
const state = next.value;
this.listings.update((listings) =>
[...listings.filter((l) => l.state.id !== state.id), { stub, state }].sort((a, b) =>
a.state.name.localeCompare(b.state.name),
),
);
}
}

The loop is the same in every framework; only the write changes. Because each update replaces the array instead of mutating it, store the list in shallow state: a signal in Angular, a shallowRef in Vue, or $state.raw in Svelte. Deep reactive state, such as Vue's ref and Svelte's $state, wraps everything it holds in proxies, including the stubs, which are themselves proxies to objects in Prismatic.

Keep the stub next to its state. Your template renders the state, and your event handlers call actions on the stub, such as stub.instances() and stub.createInstance().

To read a stub's state once, such as a connection's label, read the first value and cancel the stream. The example's solis.ts has a firstState helper that does this.

solis-core doesn't fetch integration icons. To display them, see Displaying integration avatars.

Configure an instance​

Configuring an integration follows the same steps as in React: reuse or create the customer's instance, load its configuration, run init for suggested values, then save and deploy.

Open an integration's configuration (Angular, excerpt)
// Reuse the customer's instance, or create a never-deployed one.
const [existing] = await stub.instances();
const instance = existing ?? (await stub.createInstance({ name: state.name }));

// The version being configured decides who renders its configuration.
const config = await instance.configuration();
const configState = await config.refresh();

Check configurationExperience on the configuration, not on the marketplace listing. The listing's value describes only the integration's latest version, and a customer's instance may run an older one. For headless configurations, render your own form; for hosted ones, open the config wizard.

Check action results​

Configuration and connection actions, such as init, save, connections.create, and authorize, resolve to a result instead of throwing: { status: "success", data } or { status: "error", error }. Check status before using data. Instance actions, such as createInstance, deploy, and delete, resolve with no result and reject when they fail, so wrap them in try/catch.

Save a headless configuration and deploy the instance
const saved = await config.save({ value, connections });
if (saved.status === "error") {
// Prismatic validated `value` against the integration's schema.
// Invalid fields are on `saved.error.fields`, each with a `path` and `message`.
showFieldErrors(saved.error.fields ?? []);
return;
}
// Saving stores values. Deploying makes the instance run them.
await instance.deploy();

The example's headless-setup.ts walks through the full headless configuration flow: pick connections, run init for suggested values, render the integration's form, then save and deploy. Each form lives in its own component, such as slack-form.ts, which calls the integration's server functions to list choices.

Fall back to the config wizard for hosted integrations​

A headless marketplace can still list integrations whose configuration uses Prismatic's config wizard. For hosted configurations, open the wizard with client.openVisibleFrame(). It takes the container element itself, so use a template reference: viewChild in Angular, a template ref in Vue, or bind:this in Svelte.

Open the config wizard in a dialog (Angular)
if (configState.configurationExperience === "hosted") {
this.mount = await this.client!.openVisibleFrame({
url: `/configure-instance/${encodeURIComponent(configState.instanceId)}/?reconfigure=true`,
container: this.frame().nativeElement,
title: `Configure ${configState.integrationName}`,
// The wizard deploys the instance itself, then closes.
onClose: () => this.popover().nativeElement.close(),
});
}
// Open the dialog after the frame is ready, so it never opens blank.
this.popover().nativeElement.showModal();

openVisibleFrame resolves once the wizard is ready. When your dialog closes, for any reason, call mount.dispose() to remove the iframe. Disposing the client also disposes any mounts it opened.

openVisibleFrame appends its iframe to the container. The container must stay in the DOM while the wizard is open; if your framework removes it, for example with a conditional block, the wizard goes with it.

When a customer connects a new OAuth 2.0 account, your app opens the provider's consent screen in a new window. Browsers block window.open unless it runs in response to a click, and an await before it loses that permission. Open a blank window first, then create the connection and point the window at the consent URL:

Open the consent window before awaiting (excerpt from solis.ts)
// Before any `await`, while the browser still treats this as part of the click.
const consent = window.open("about:blank", "_blank");
if (!consent) throw new Error("Allow pop-ups for this site, then try again.");
consent.opener = null;

const created = await client.authenticated!.connections.create({
templateId: template.id,
});
if (created.status === "error") throw created.error;
const connection = created.data as unknown as ConnectionStub;

const authorization = await connection.authorize();
if (authorization.status === "error") throw authorization.error;
consent.location.href = authorization.data.url;
// Then read connection.state() until its status is ACTIVE.

Call the function that does this directly from the click handler. Wrapping it in a framework helper that awaits first, such as a loading-state utility, triggers the pop-up blocker. The example's createConnection also handles client-credentials connections, which need no consent window, and waits for the connection to become active.

Run the Angular example​

The Angular example app puts all of this together:

FileWhat it shows
server.tsA backend route that signs the embedded user JWT
prismatic-auth.tsThe app-wide token service
headless-marketplace/A marketplace built from the app's own components, with @prismatic-io/solis-core
headless-setup/Headless configuration: connection pickers, integration-specific forms, and save and deploy
hosted-marketplace/For comparison, Prismatic's hosted marketplace in an iframe, with @prismatic-io/embedded

To run it, you need Bun, your organization ID, and an RSA key pair whose public key you've uploaded to Prismatic. The example's README lists the environment variables and how to import the example integrations the headless marketplace configures.

git clone https://github.com/prismatic-io/embedded.git
cd embedded/examples/angular-app
cp .env.example .env.local # Then fill in your values
bun install
bun run dev

bun run dev starts the token server on port 3001 and the Angular app at http://localhost:4200.

Next steps​