Skip to main content

Build a Custom Marketplace with Solis

In this tutorial, you build an integration marketplace with your app's own components using Solis, Prismatic's custom UI SDK. The marketplace lists every integration available to the signed-in customer, with a search box, a category filter, and a status badge for each integration the customer has set up. When the customer clicks Set up, your app creates an instance and opens its configuration in a dialog.

A custom marketplace of integration cards with search, a category filter, and status badges

The code comes from the Headless Configuration page, headless-configuration.tsx, of Prismatic's example embedded app. This tutorial uses Solis React (@prismatic-io/solis-react). If your app doesn't use React, the same resources and actions are available from @prismatic-io/solis-core.

Before you begin​

You need:

  • A Prismatic organization with at least one integration that's available and deployable in your marketplace. See Preparing for marketplace.
  • A customer in your organization to sign in as.
  • A backend endpoint that signs a JWT for the signed-in user. Solis accepts the same token as the embedded SDK. See Authenticate embedded users.
  • A React 18 or 19 app. Solis React doesn't support server-side rendering, so render the marketplace on the client.
  • Bun, to run the example app.

Integrations that use config pages work without any changes: Solis opens Prismatic's config wizard for them. To configure integrations with your own forms, the integrations must use headless configuration, which requires the headless configuration feature in your organization.

Run the example

Clone the example embedded app and install its dependencies:

git clone https://github.com/prismatic-io/embedded.git
cd embedded/examples/example-embedded-app
cp .env.example .env.local

Fill in .env.local with your Prismatic URL, organization ID, customer external ID, and the path to your signing key, as described in the app's README. Then start the app:

bun install
bun run dev

Open http://localhost:3000 and select Headless Configuration in the sidebar. The page lists your marketplace's integrations. Integrations with config pages carry a Prismatic wizard badge, and headless integrations carry a Set up in Acme badge.

To see a headless integration configured with the app's own form, import the Fake CRM integration in integrations/fake-crm/. See Get started with headless configuration.

The rest of this tutorial builds the same marketplace in your app.

Install Solis React

Install solis-react:

npm install @prismatic-io/solis-react@latest
Start a Solis session

Wrap the part of your app that shows integrations in PrismaticProvider. Pass your Prismatic URL and the JWT your backend signed for the signed-in user:

Integrations.tsx
import { PrismaticProvider, usePrismatic } from "@prismatic-io/solis-react";

export function Integrations({ token }: { token: string }) {
return (
<PrismaticProvider prismaticUrl="https://app.prismatic.io" auth={{ token }}>
<SessionReady>
<Marketplace />
</SessionReady>
</PrismaticProvider>
);
}

function SessionReady({ children }: { children: React.ReactNode }) {
const session = usePrismatic();
if (session.status === "loading") return <p>Connecting…</p>;
if (session.status === "error") return <p>{session.error.message}</p>;
return children;
}

Use your region's Prismatic URL, or your custom domain, for prismaticUrl. When your app refreshes the token, pass the new one; the provider applies it to the live session.

List the marketplace

useMarketplace() lists the integrations available to the customer. It accepts a search term and a category, and useMarketplaceFilterOptions() lists the categories and labels in your marketplace for a filter control:

Marketplace.tsx
import {
useMarketplace,
useMarketplaceFilterOptions,
} from "@prismatic-io/solis-react";
import { useState } from "react";

export function Marketplace() {
const [search, setSearch] = useState("");
const [category, setCategory] = useState("");
const [selected, setSelected] = useState<string | null>(null);
const filterOptions = useMarketplaceFilterOptions();
const marketplace = useMarketplace({
includeActiveIntegrations: true,
...(search ? { search } : {}),
...(category ? { category } : {}),
});

return (
<div>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search integrations"
/>
<select
value={category}
onChange={(event) => setCategory(event.target.value)}
>
<option value="">All categories</option>
{filterOptions.status === "success"
? filterOptions.data.categories.map((name) => (
<option key={name} value={name}>
{name}
</option>
))
: null}
</select>

{marketplace.status === "loading" ? (
<p>Loading integrations…</p>
) : marketplace.status === "error" ? (
<p>{marketplace.error.message}</p>
) : (
<div className="grid">
{marketplace.data.items.map((item) => (
<IntegrationCard
key={item.id}
item={item}
onOpen={() => setSelected(item.id)}
/>
))}
</div>
)}

{selected ? (
<SetupDialog
integrationId={selected}
onClose={() => setSelected(null)}
/>
) : null}
</div>
);
}

includeActiveIntegrations also lists integrations that aren't in your marketplace but that the customer has an instance of, such as a customer-specific integration you deployed for them. The filter is compared by value, so an inline object doesn't refetch the list on every render. The example app debounces the search box with a useDebouncedValue hook, so typing doesn't start a request for every keystroke.

Render a card for each integration

Each item in the list is its own resource. Its data includes the integration's name, description, category, avatarUrl, and configurationExperience, plus instances, the customer's instances of the integration. Each instance's lifecycle summarizes its status:

IntegrationCard.tsx
import type {
InstanceLifecycle,
ListItem,
MarketplaceIntegrationResource,
} from "@prismatic-io/solis-react";

const LIFECYCLE_LABELS: Record<InstanceLifecycle, string> = {
notDeployed: "Not deployed",
needsReconfiguration: "Needs review",
needsUserConfiguration: "Needs your setup",
paused: "Paused",
pendingChanges: "Changes not deployed",
active: "Active",
};

export function IntegrationCard({
item,
onOpen,
}: {
item: ListItem<MarketplaceIntegrationResource>;
onOpen: () => void;
}) {
if (item.status !== "success") return <div className="card loading" />;
const integration = item.data;
const [instance] = integration.instances;
const status =
instance?.status === "success"
? LIFECYCLE_LABELS[instance.data.lifecycle]
: null;

return (
<div className="card">
{status ? <span className="badge">{status}</span> : null}
<h3>{integration.name}</h3>
<p>{integration.description}</p>
<span className="tag">
{integration.configurationExperience === "headless"
? "Set up in Acme"
: "Prismatic wizard"}
</span>
{integration.category ? (
<span className="tag">{integration.category}</span>
) : null}
<button onClick={onOpen}>{instance ? "Manage" : "Set up"}</button>
</div>
);
}

To display the integration's icon, fetch avatarUrl with the customer's token. See Displaying integration avatars.

Activate an integration

When the customer opens an integration they haven't set up, create an instance with the integration's createInstance action. The new instance's lifecycle is notDeployed until the customer finishes configuring it. Check permissions.createInstance first; when it isn't allowed, its reason explains why:

SetupDialog.tsx
import { useMarketplaceIntegration } from "@prismatic-io/solis-react";
import { useEffect, useRef, useState } from "react";

export function SetupDialog({
integrationId,
onClose,
}: {
integrationId: string;
onClose: () => void;
}) {
const integration = useMarketplaceIntegration(integrationId);
const [createdId, setCreatedId] = useState<string | null>(null);
const [error, setError] = useState("");
const started = useRef(false);

const data = integration.status === "success" ? integration.data : null;
// Until the listing reloads, it may not include the instance created here.
const instanceId = data?.instances[0]?.id ?? createdId;
const { createInstance } = integration.actions;

useEffect(() => {
if (!data || instanceId || started.current) return;
if (!data.permissions.createInstance.allowed) return;
started.current = true;
createInstance.execute({ name: data.name }).then((result) => {
if (result.status === "success") setCreatedId(result.data.id);
else setError(result.error.message);
});
}, [data, instanceId, createInstance]);

return (
<dialog open>
{!data ? (
<p>Loading…</p>
) : error ? (
<p>{error}</p>
) : instanceId ? (
<>
<h2>{data.name}</h2>
<InstanceSetup instanceId={instanceId} onClose={onClose} />
</>
) : !data.permissions.createInstance.allowed ? (
<p>
You can't activate {data.name}:{" "}
{data.permissions.createInstance.reason}
</p>
) : (
<p>Activating {data.name}…</p>
)}
</dialog>
);
}

Actions never throw. execute() resolves to { status: "success", data } or { status: "error", error }, and the marketplace list refreshes on its own after the instance is created.

Open the configuration

useConfiguration({ instanceId }) loads the configuration of the version the instance runs. Its configurationExperience decides who renders it:

  • hosted: The integration uses config pages. Render HostedConfiguration, which shows Prismatic's config wizard inside your dialog and calls onClose when the customer finishes or cancels.
  • headless: The integration uses headless configuration. Render your own form, then save and deploy the customer's values.
InstanceSetup.tsx
import {
HostedConfiguration,
useConfiguration,
} from "@prismatic-io/solis-react";

export function InstanceSetup({
instanceId,
onClose,
}: {
instanceId: string;
onClose: () => void;
}) {
const configuration = useConfiguration({ instanceId });
if (configuration.status === "loading") return <p>Loading…</p>;
if (configuration.status === "error") {
return <p>{configuration.error.message}</p>;
}

if (configuration.data.configurationExperience === "hosted") {
return (
<div style={{ height: "70vh" }}>
<HostedConfiguration
instanceId={instanceId}
className="hosted-wizard"
onClose={onClose}
/>
</div>
);
}

return <MyConfigurationForm configuration={configuration} />;
}

Route on the configuration's configurationExperience, not the marketplace listing's. The listing describes the integration's latest version, and the configuration describes the version the customer's instance runs.

After the wizard closes, Solis refreshes the instance, the marketplace, and configurations, so the card's status badge updates without a reload.

For headless integrations, MyConfigurationForm is your form. It runs the configuration's init action for suggested values, save to validate and store the customer's values, and the instance's deploy action to make them live. See Configure an instance for each step, and the example app's configuration-editor.tsx for a complete form.

Manage deployed instances

When the customer opens a headless integration that's already deployed, show the instance's status and actions instead of the setup form. For example, InstanceSetup can render InstanceSummary when the instance's lifecycle is anything other than notDeployed or needsReconfiguration, with a button that opens the form again. For integrations with config pages, the example app reopens Prismatic's config wizard instead, so the customer can review and change their settings there. useInstance(instanceId) returns the instance with pause, resume, remove, upgrade, and deploy actions, each gated by a permission of the same name:

InstanceSummary.tsx
import { useInstance } from "@prismatic-io/solis-react";

export function InstanceSummary({ instanceId }: { instanceId: string }) {
const instance = useInstance(instanceId);
if (instance.status !== "success") return null;
const { lifecycle, permissions, update } = instance.data;
const { pause, resume, remove, upgrade, deploy } = instance.actions;

return (
<div>
{update && !update.requiresReconfiguration ? (
<button
disabled={!permissions.upgrade.allowed}
onClick={async () => {
const moved = await upgrade.execute();
if (moved.status === "success") await deploy.execute();
}}
>
Update to version {update.versionNumber}
</button>
) : null}
{lifecycle === "paused" ? (
<button
disabled={!permissions.resume.allowed}
onClick={() => resume.execute()}
>
Resume
</button>
) : (
<button
disabled={!permissions.pause.allowed}
onClick={() => pause.execute()}
>
Pause
</button>
)}
<button
disabled={!permissions.remove.allowed}
onClick={() => remove.execute()}
>
Remove
</button>
</div>
);
}

An update whose requiresReconfiguration is true changes the shape of the configuration, so the customer reviews their values before moving to it. See Updates.

What you learned​

  • PrismaticProvider starts a Solis session with the same JWT the embedded SDK uses.
  • useMarketplace() and useMarketplaceFilterOptions() supply the integrations, search, and categories, and your app displays them however it likes.
  • createInstance activates an integration, and the instance's lifecycle tells your app what to show.
  • HostedConfiguration opens Prismatic's config wizard for integrations with config pages, and your own forms configure headless integrations.

Next steps​