# 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](https://prismatic.io/docs/embed/headless.md), 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](https://github.com/prismatic-io/embedded/tree/main/examples/angular-app), with equivalent Vue and Svelte code where the framework changes the approach. If your app uses React, use [Solis React](https://prismatic.io/docs/embed/headless/react.md) instead; its hooks handle the lifecycle for you.

## Install the package[​](#install-the-package "Direct link to Install the package")

```bash
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](https://prismatic.io/docs/embed/headless/install.md).

## Get a token[​](#get-a-token "Direct link to Get a token")

`solis-core` needs an embedded user JWT, signed by your backend as described in [Embedded Authentication](https://prismatic.io/docs/embed/authentication.md). 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](https://prismatic.io/docs/embed/authentication.md#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[​](#how-solis-core-represents-data "Direct link to 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`](https://developer.mozilla.org/en-US/docs/Web/API/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](https://prismatic.io/docs/embed/custom-marketplace-ui.md#integration-data) and [Building configuration UIs with Solis React](https://prismatic.io/docs/embed/headless/react.md).

## Tie the client to the page[​](#tie-the-client-to-the-page "Direct link to 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.

- Angular
- Vue
- Svelte

src/app/headless-marketplace/headless-marketplace.ts (excerpt)

```ts
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.
  }
}

```

src/views/HeadlessMarketplace.vue (script setup excerpt)

```ts
import { onBeforeUnmount, watch } from "vue";
import { type Client, createClient } from "@prismatic-io/solis-core";
import { usePrismaticAuth } from "../prismatic-auth";

const auth = usePrismaticAuth();
let client: Client | undefined;
let destroyed = false;

watch(
  auth.token,
  (token) => {
    if (!token) return;
    // A refreshed token for the same user keeps every stub valid.
    if (client) client.authenticate(token);
    else load(token);
  },
  { immediate: true },
);

onBeforeUnmount(() => {
  destroyed = true;
  client?.dispose();
});

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

```

src/routes/headless/+page.svelte (script excerpt)

```ts
import { onDestroy } from "svelte";
import { type Client, createClient } from "@prismatic-io/solis-core";
import { auth } from "$lib/prismatic-auth.svelte";

let client: Client | undefined;
let destroyed = false;

$effect(() => {
  const token = auth.token;
  if (!token) return;
  // A refreshed token for the same user keeps every stub valid.
  if (client) client.authenticate(token);
  else load(token);
});

onDestroy(() => {
  destroyed = true;
  client?.dispose();
});

async function load(jwt: string) {
  const created = await createClient({ prismaticUrl: auth.prismaticUrl, jwt });
  if (destroyed) return created.dispose();
  client = created;
  // 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[​](#list-the-marketplace "Direct link to 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](https://prismatic.io/docs/embed/custom-marketplace-ui.md#listing-marketplace-integrations). 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)

```ts
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`](https://github.com/prismatic-io/embedded/blob/main/examples/angular-app/src/app/headless-marketplace/solis.ts) has a `firstState` helper that does this.

`solis-core` doesn't fetch integration icons. To display them, see [Displaying integration avatars](https://prismatic.io/docs/embed/custom-marketplace-ui.md#displaying-integration-avatars).

## Configure an instance[​](#configure-an-instance "Direct link to 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)

```ts
// 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](#fall-back-to-the-config-wizard-for-hosted-integrations).

### Check action results[​](#check-action-results "Direct link to 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

```ts
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`](https://github.com/prismatic-io/embedded/blob/main/examples/angular-app/src/app/headless-marketplace/headless-setup/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`](https://github.com/prismatic-io/embedded/blob/main/examples/angular-app/src/app/headless-marketplace/headless-setup/slack-form.ts), which calls the integration's server functions to list choices.

### Fall back to the config wizard for hosted integrations[​](#fall-back-to-the-config-wizard-for-hosted-integrations "Direct link to Fall back to the config wizard for hosted integrations")

A headless marketplace can still list integrations whose configuration uses Prismatic's [config wizard](https://prismatic.io/docs/integrations/config-wizard.md). 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)

```ts
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.

## Open OAuth consent windows from the click[​](#open-oauth-consent-windows-from-the-click "Direct link to Open OAuth consent windows from the click")

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)

```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`](https://github.com/prismatic-io/embedded/blob/main/examples/angular-app/src/app/headless-marketplace/solis.ts) also handles client-credentials connections, which need no consent window, and waits for the connection to become active.

## Run the Angular example[​](#run-the-angular-example "Direct link to Run the Angular example")

The [Angular example app](https://github.com/prismatic-io/embedded/tree/main/examples/angular-app) puts all of this together:

| File                                                                                                                                     | What it shows                                                                               |
| ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| [`server.ts`](https://github.com/prismatic-io/embedded/blob/main/examples/angular-app/server.ts)                                         | A backend route that signs the embedded user JWT                                            |
| [`prismatic-auth.ts`](https://github.com/prismatic-io/embedded/blob/main/examples/angular-app/src/app/prismatic-auth.ts)                 | The app-wide token service                                                                  |
| [`headless-marketplace/`](https://github.com/prismatic-io/embedded/tree/main/examples/angular-app/src/app/headless-marketplace)          | A marketplace built from the app's own components, with `@prismatic-io/solis-core`          |
| [`headless-setup/`](https://github.com/prismatic-io/embedded/tree/main/examples/angular-app/src/app/headless-marketplace/headless-setup) | Headless configuration: connection pickers, integration-specific forms, and save and deploy |
| [`hosted-marketplace/`](https://github.com/prismatic-io/embedded/tree/main/examples/angular-app/src/app/hosted-marketplace)              | For comparison, Prismatic's hosted marketplace in an iframe, with `@prismatic-io/embedded`  |

To run it, you need [Bun](https://bun.sh), your organization ID, and an RSA key pair whose public key you've [uploaded to Prismatic](https://prismatic.io/docs/embed/authentication.md#importing-your-own-private-signing-key). The [example's README](https://github.com/prismatic-io/embedded/blob/main/examples/angular-app/README.md) lists the environment variables and how to import the example integrations the headless marketplace configures.

```bash
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[​](#next-steps "Direct link to Next steps")

* [Build a custom marketplace](https://prismatic.io/docs/get-started/embedded-marketplace/custom-marketplace.md)
* [Get started with headless configuration](https://prismatic.io/docs/get-started/build-integrations/headless-configuration.md)
