Building Configuration UIs with Solis React
This guide shows how to build a marketplace and a rich configuration experience in React with Solis React (@prismatic-io/solis-react), the React package of Solis, Prismatic's custom UI SDK.
The code comes from the Headless Configuration page of the example embedded app, which you can run and read alongside this guide.
Install the SDK
Install solis-react:
npm install @prismatic-io/solis-react@latest
solis-react supports React 18 and 19.
It doesn't support server-side rendering: during a server render, every hook is loading.
For details on connecting, refreshing tokens, and verifying the session, see Installing the headless SDK.
Add the provider
Wrap the part of your app that uses Prismatic in PrismaticProvider.
Pass your Prismatic URL and an embedded user's JWT, signed by your backend as described in Authenticating embedded users:
import { PrismaticProvider, usePrismatic } from "@prismatic-io/solis-react";
export function Integrations({ token }: { token: string }) {
return (
<PrismaticProvider prismaticUrl="https://app.prismatic.io" auth={{ token }}>
<Marketplace />
</PrismaticProvider>
);
}
When your app refreshes the token, pass the new one. The provider applies it to the live session without starting over.
usePrismatic() returns the session.
It's loading until the hidden frame connects and accepts the token:
const session = usePrismatic();
if (session.status === "loading") return <Spinner />;
if (session.status === "error")
return <p>Could not connect: {session.error.message}</p>;
Resources, actions, and permissions
Every hook follows the same rules:
- A hook returns a resource:
{ status: "loading" },{ status: "error", error }, or{ status: "success", data }. Readdataonly after checkingstatus. - Changes are actions on
resource.actions.execute()resolves to a result,{ status: "success", data }or{ status: "error", error }, and never throws. An action also has its ownstatus, so you can disable a button while it runs. - Each guarded action has a permission at
data.permissions.<action>:{ allowed: true }, or{ allowed: false, reason }.
List the marketplace
useMarketplace() lists the integrations the customer can activate.
Each item is its own resource:
import { useMarketplace } from "@prismatic-io/solis-react";
function Marketplace() {
const marketplace = useMarketplace({ includeActiveIntegrations: true });
if (marketplace.status === "loading") return <Spinner />;
if (marketplace.status === "error") return <p>{marketplace.error.message}</p>;
return marketplace.data.items.map((item) =>
item.status === "success" ? (
<IntegrationCard
key={item.id}
name={item.data.name}
experience={item.data.configurationExperience}
instances={item.data.instances}
/>
) : null,
);
}
Each listing includes configurationExperience, headless or hosted, and instances, the customer's instances of that integration.
Activate an integration
useMarketplaceIntegration(id) returns one listing.
Its createInstance action creates an instance that isn't deployed yet:
const integration = useMarketplaceIntegration(integrationId);
if (integration.status !== "success") return null;
const { createInstance } = integration.actions;
return (
<button
disabled={
!integration.data.permissions.createInstance.allowed ||
createInstance.status === "loading"
}
onClick={() => createInstance.execute({ name: integration.data.name })}
>
Activate {integration.data.name}
</button>
);
Lists refresh on their own afterward, so the new instance appears in integration.data.instances.
Configure an instance
Configuring an instance takes four steps: load the configuration, run init, save, and deploy.
Load the configuration
useConfiguration({ instanceId }) returns the configuration of the version the instance runs.
Its data includes the schema, the ui layout, configurationExperience, and serverFunctions:
const instance = useInstance(instanceId);
const configuration = useConfiguration({ instanceId });
ui is the layout the integration declares: { type: "uiSchema", value } with a JSON Forms layout, or { type: "custom", value } with a value your app defines.
It's absent or null when the integration has no layout.
To use a JSON Forms layout:
const { ui } = configuration.data;
const uiSchema = ui?.type === "uiSchema" ? ui.value : undefined;
Run init
init returns the values the integration suggests.
It saves nothing, so it's safe to run as soon as the form opens:
// What the example integration's init returns
interface InitProposal {
configuration: FakeCrmConfiguration;
unresolvedReasons?: string[];
}
const [proposal, setProposal] = useState<InitProposal | null>(null);
useEffect(() => {
if (configuration.status !== "success") return;
configuration.actions.init.execute({}).then((result) => {
if (result.status === "success") setProposal(result.data as InitProposal);
});
}, [configuration.status]);
init's result includes configuration, the suggested values, and userConfiguration when the integration asks each user for settings.
The integration can add other properties for your app.
The example integration adds unresolvedReasons.
Use configuration as your form's starting values, and show any unresolvedReasons for the customer to review.
Prismatic doesn't validate init's suggestions against the schema.
It validates the values your app saves.
Save and deploy
save stores the customer's values.
Prismatic validates them against the schema first.
Then deploy makes the instance run with them:
const [fieldErrors, setFieldErrors] = useState<
readonly { path: string | null; message: string }[]
>([]);
const submit = async (value: FakeCrmConfiguration) => {
const saved = await configuration.actions.save.execute({ value });
if (saved.status === "error") {
// PRISMATIC_CONFIGURATION_INVALID lists each invalid field
setFieldErrors(saved.error.fields ?? []);
return;
}
await instance.actions.deploy.execute();
};
Saving never deploys.
Until your app calls deploy, a deployed instance keeps running with its previous values, and its lifecycle is pendingChanges.
A new instance stays notDeployed until its first deploy.
Flow schedules
An integration can let whoever deploys an instance choose when a flow runs, by giving the flow schedule: { fromDeployer: true }.
configuration.data.flows lists the flows of the version being configured, with each flow's id, name, stableId, and scheduleFromDeployer.
Pass schedules to save alongside the values:
await configuration.actions.save.execute({
value: formValues,
flows: [
{
flowId: selectedFlow.id,
schedule: { expression: "0 9 * * *", timezone: "America/Phoenix" },
},
],
});
- An
expressionis a cron string,"once", or"none"to turn off scheduled runs. A missing ornulltimezone means UTC. - Omit
flows, or a flow, to keep its current schedule. A flow that newly requires a schedule from the deployer needs one before the configuration is complete. - When saving an update, use the flow IDs of the version you're moving to. Schedules carry over for flows with the same stable ID.
The instance's data.flows includes each flow's saved schedule.
Render the form
Your app can render a form generically from configuration.data.schema, or build a form for one integration.
The example app's Fake CRM form is built for one integration.
It still reads each field's label and whether it's required from the schema, so a new field in the integration appears without an app change.
Presenting a configuration
Solis doesn't decide how a configuration looks, so you can present the same configuration in whatever layout fits your app and your customers:
| Presentation | Suits |
|---|---|
| Single page | Short setups, and customers who want to see every question and change answers in any order |
| Multi-step wizard | Longer setups, and questions whose choices depend on an earlier answer, such as fields that depend on a record type |
| One-click connect | Integrations whose init can suggest every required value, so the customer only authorizes a connection |
The layout changes only what your app renders.
Every presentation loads the configuration, runs init, saves, and deploys with the same calls, and Prismatic validates the saved values the same way.

The example app's Presentation Modes page presents the Fake CRM configuration as a single page or a two-step wizard, and lets the customer switch between them mid-setup. These techniques keep both layouts working:
- Keep the values in one place.
The form holds the customer's answers in one state, and each layout arranges the same pieces: the record type picker, the field mapper, and the submit button.
Switching layouts changes only a
layoutprop, so React keeps the answers. If each layout is its own component, lift the values into a parent, or switching resets the form. - Gate each step on its answers. A step's Next button can wait for the values the next step depends on, and the submit button can wait for every required field in the schema. Prismatic still validates the values when you save, and returns any errors by field path, so show each one at its field.
- Run server functions when their result is on screen. Call a server function, such as a live preview, only once its step is showing and its inputs are known. Pass the customer's unsaved values as inputs, because a server function reads only saved configuration.
- Take the order from the integration.
JSON Schema properties have no order, so read the field order from the integration's
uilayout. An integration can declare{ type: "custom", value }with a layout your app defines, such as its wizard's steps.
For a walkthrough of the example, see Present a configuration as a page or a wizard. For one-click connect, see Build a one-click connect list with Solis.
Calling server functions
useServerFunction(configuration, key) returns an action that runs one of the integration's server functions.
Type its inputs and result with generics:
interface Field {
name: string;
label: string;
}
const listFields = useServerFunction<{ recordType: string }, Field[]>(
configuration,
"listFields",
);
const result = await listFields.execute({ inputs: { recordType: "Contact" } });
if (result.status === "success") setFields(result.data);
Things to know:
- One call at a time. An action runs one call at a time.
Calling
execute()while a call runs resolves to aPRISMATIC_ACTION_BUSYerror. To run the same function side by side, calluseServerFunctionmore than once. - Results aren't validated. The generics type the result, but nothing checks it at runtime.
- Unsaved values are inputs. A server function reads only saved configuration from its context. Pass the customer's unsaved choices as inputs.
To refetch a function whenever its inputs change, like a query, the example app uses a small hook, useServerFunctionQuery.
It runs the function again with the newest inputs when a call finishes, so calls never overlap.
Connections
configuration.data.connections is a resource that loads after the configuration.
It lists the connections init and each server function need.
Each requirement has a key, a label, and options, the customer's existing connections that can fill it:
const { connections } = configuration.data;
if (connections.status === "success") {
for (const requirement of connections.data.init) {
console.log(
requirement.key,
requirement.options.map((option) => option.id),
);
}
}
Keep the customer's choices as { key, id } selections, and pass them to init and server functions:
await configuration.actions.init.execute({ connections: selections });
await listChannels.execute({ inputs: { search }, connections: selections });
A requirement backed by a customer-activated connection can create a new connection with requirement.actions.createConnection.
For OAuth 2.0, it opens the provider's consent screen, so call it directly from a click handler.
Routing between headless and hosted configuration
Integrations that use config pages report a configurationExperience of hosted.
For those, render HostedConfiguration, which opens Prismatic's config wizard:
import { HostedConfiguration } from "@prismatic-io/solis-react";
if (configuration.data.configurationExperience === "hosted") {
return (
<HostedConfiguration
instanceId={instanceId}
className="h-full"
onClose={close}
/>
);
}
return <MyConfigurationForm configuration={configuration} />;
After the wizard changes something, the SDK refreshes instances, the marketplace, and configurations, so the rest of your UI updates.
Updates
An instance's data.update is null when the instance is on the newest version.
Otherwise it's { integrationVersionId, versionNumber, requiresReconfiguration }:
-
When
requiresReconfigurationisfalse, the configuration's shape didn't change. Callupgrade, thendeploy:const moved = await instance.actions.upgrade.execute();if (moved.status === "success") await instance.actions.deploy.execute(); -
When
requiresReconfigurationistrue, load the new version's configuration and configure it as usual. Saving it moves the instance to the new version:const configuration = useConfiguration({instanceId,integrationVersionId: instance.data.update?.integrationVersionId,});After you save, the instance has no update, so remember which version is under review until the customer finishes.
See Configuration versions and init for how integration authors decide which kind of update they publish.
Instance status
An instance's data.lifecycle summarizes its status:
| Lifecycle | Meaning |
|---|---|
notDeployed | Created, but never deployed |
needsReconfiguration | The instance needs its configuration reviewed |
needsUserConfiguration | The current user needs to save their user-level settings |
paused | Paused |
pendingChanges | Saved changes haven't been deployed |
active | Running |
The instance's actions include pause, resume, and remove, each with a matching permission.
Next steps
- Get started with headless configuration to run the example end to end