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.

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.
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:
npm install @prismatic-io/solis-react@latest
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:
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.
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:
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.
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:
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.
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:
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.
useConfiguration({ instanceId }) loads the configuration of the version the instance runs.
Its configurationExperience decides who renders it:
hosted: The integration uses config pages. RenderHostedConfiguration, which shows Prismatic's config wizard inside your dialog and callsonClosewhen the customer finishes or cancels.headless: The integration uses headless configuration. Render your own form, then save and deploy the customer's values.
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.
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:
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
PrismaticProviderstarts a Solis session with the same JWT the embedded SDK uses.useMarketplace()anduseMarketplaceFilterOptions()supply the integrations, search, and categories, and your app displays them however it likes.createInstanceactivates an integration, and the instance'slifecycletells your app what to show.HostedConfigurationopens Prismatic's config wizard for integrations with config pages, and your own forms configure headless integrations.
Next steps
- Build a one-click connect list for integrations that need little configuration
- Building configuration UIs with Solis React
- Get started with headless configuration
- Custom marketplace UI