Skip to content

How to Build a Schema-Driven Post-Connection Configuration UI (React & JSON Schema Guide)

Learn how to build dynamic, schema-driven post-connection configuration UIs for SaaS integrations using React JSON Schema Form to improve activation rates.

Nachi Raman Nachi Raman · · 12 min read
How to Build a Schema-Driven Post-Connection Configuration UI (React & JSON Schema Guide)

You just closed a major enterprise account. The prospect loves your core product. They finally click "Connect" on your shiny new Salesforce integration. The OAuth dance completes successfully. They are redirected back to your application, ready to sync their data.

Then, they hit a brick wall.

Your UI presents a blank text input asking for their "Salesforce Custom Object API Name" and a "Record Type ID". The user, a VP of Sales, has no idea what a Record Type ID is. They open a new tab to search for it, get distracted by a Slack message, and close the window.

That integration is now functionally dead. Your integration activation metric just took a direct hit.

Meanwhile, on the engineering side, your frontend developer opens a PR that adds another 400-line React component to collect a Pipeline ID and a custom field mapping—because Salesforce is different from HubSpot, which is different from Pipedrive, which is different from the twelve other CRMs on the roadmap.

Multiply this by every integration you plan to ship this year, and you have the real bottleneck in customer-facing integrations: the post-connection configuration UI. Not OAuth. Not schema normalization. The forms.

This guide gives you a copy-pasteable JSON Schema, a working React renderer, and an architecture for driving every integration's setup screen from a declarative config instead of hardcoded components. If you are tired of shipping bespoke forms for every new connector, this is the pattern.

The Post-Connection Configuration Problem in SaaS

"Connected Successfully" is not the end of the integration journey. It is barely the beginning.

Post-connection configuration is the setup phase between a successful OAuth handshake and a working sync. It is where the user picks which workspace to pull from, which pipelines to monitor, or how their custom sfdc_account_tier__c field maps to your internal tier field. It is also where most integrations quietly die.

The numbers are ugly. A 62-company benchmark study by Userpilot found the median B2B SaaS activation rate sits at just 37.5%—roughly two-thirds of new users never reach the moment where the product actually delivers value. Worse, Amplitude's 2025 analysis shows over 98% of new users churn within two weeks if they never hit a value milestone. For most B2B products, that milestone is a live integration syncing real data.

What makes it worse: active integrations are a compounding retention lever. Analytics from integration platforms show users with 5+ active integrations have 36% higher retention, and accounts with 11+ native integrations show 30% higher willingness to pay. Every activation you leak here is retention revenue you never see.

The usual failure mode looks like this:

  1. User completes OAuth with Asana.
  2. Your app redirects to /integrations/asana/setup.
  3. A React component renders a blank text input labeled "Workspace ID".
  4. User has no idea what a Workspace ID is or where to find it.
  5. Tab closed. Integration dead.

The root cause is not that your engineers wrote a bad form. It is that they wrote a form at all. Every hardcoded configuration screen is a maintenance liability.

  • <SalesforceConfigForm /> requires fetching custom objects.
  • <AsanaConfigForm /> requires a workspace ID dropdown.
  • <JiraConfigForm /> requires mapping issue types and custom fields.

Every time a product manager wants to add a new configuration option to an existing integration, a frontend engineer has to write new state management logic, handle new API calls, update the validation schema, and deploy the entire frontend application. This approach simply does not scale. For the deeper UX playbook on why this happens, see our guide on post-connection configuration UI patterns.

Why Schema-Driven UIs Are the Right Solution

To stop the endless cycle of hardcoding setup screens, engineering teams must decouple the frontend rendering from the backend integration logic.

A schema-driven UI is an architectural pattern where the backend dictates the structure, validation, and layout of a user interface via a JSON payload. It separates three concerns that are usually tangled:

  • What to collect (the schema)
  • How to render it (the renderer)
  • Where the options come from (the data source)

With this split, adding a new integration becomes a config change, not a deploy.

flowchart TD
    User["End User"] -->|1. Completes OAuth| Backend["Integration Backend"]
    Backend -->|2. Resolves Config Needs| SchemaGen["Schema Generator"]
    SchemaGen -->|3. Returns JSON Schema| Frontend["React Application"]
    Frontend -->|4. Renders Generic Component| Form["Dynamic UI Form"]
    Form -->|5. Submits Payload| Backend

By using a standard like JSON Schema, your backend (or your unified API vendor) publishes a payload that describes the fields, validation rules, and dynamic option sources. Your frontend runs a single generic renderer that turns that schema into a form.

What you gain

  • Zero Frontend Deploys: Adding a "Sync historical data" checkbox to the HubSpot integration requires zero changes to your React codebase. A PM can add a new field by editing a JSON blob.
  • Server-Driven UI: The backend can change the form for a specific customer (e.g., exposing custom fields based on their tier) without a frontend deploy.
  • Consistent UX & Free Validation: All integration forms use the exact same underlying form renderer, ensuring uniform validation messages, button states, and error handling. JSON Schema validation gives you client-side and server-side validation from the same source of truth.

What you give up (be honest)

  • Bespoke UX polish is harder: If your Salesforce setup screen needs a full-bleed field-mapping canvas with drag-and-drop, JSON Schema alone will not get you there. You will need custom widgets.
  • Complex conditional logic gets ugly: JSON Schema's dependencies and if/then/else are workable but verbose.
  • Debugging is one level of indirection deeper: "Why is this field missing?" becomes a schema question, not a component question.

For 80% of post-connection configuration screens, the trade is worth it. In the React ecosystem, React JSON Schema Form (RJSF) is the most widely used renderer for this pattern.

Copy-Paste JSON Schema Example for Setup Screens

Here is a realistic schema for a post-OAuth setup step. The user has just connected an Asana workspace and needs to pick which workspace to sync, choose one or more projects, and map a custom field.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Asana Integration Setup",
  "type": "object",
  "required": ["workspaceId", "projectIds", "syncMode"],
  "properties": {
    "workspaceId": {
      "type": "string",
      "title": "Workspace",
      "description": "Choose the Asana workspace you want to sync.",
      "x-dataSource": {
        "endpoint": "/api/integrations/asana/workspaces",
        "valueKey": "gid",
        "labelKey": "name"
      }
    },
    "projectIds": {
      "type": "array",
      "title": "Projects to sync",
      "minItems": 1,
      "items": {
        "type": "string"
      },
      "uniqueItems": true,
      "x-dataSource": {
        "endpoint": "/api/integrations/asana/projects",
        "dependsOn": ["workspaceId"],
        "valueKey": "gid",
        "labelKey": "name"
      }
    },
    "syncMode": {
      "type": "string",
      "title": "Sync direction",
      "enum": ["one_way", "two_way"],
      "default": "one_way",
      "enumNames": ["One-way (Read Only)", "Two-way (Read & Write)"]
    },
    "customFieldMapping": {
      "type": "object",
      "title": "Map custom fields",
      "properties": {
        "priority": {
          "type": "string",
          "title": "Priority field",
          "x-dataSource": {
            "endpoint": "/api/integrations/asana/custom-fields",
            "dependsOn": ["workspaceId"],
            "valueKey": "gid",
            "labelKey": "name"
          }
        }
      }
    }
  }
}

A few things to notice about this structure:

  • x-dataSource: This is a custom extension. JSON Schema itself does not describe how to fetch remote options—you need a small convention that your renderer understands.
  • dependsOn: This encodes cascading dependencies. Projects cannot be fetched until a workspace is chosen. The renderer must re-fetch (and clear) dependent fields when a parent changes.
  • enum and enumNames: This is how JSON Schema handles static dropdowns. The enum array contains the actual values sent back to the server, while enumNames provides the human-readable labels.

Building the React Form Renderer

To render this schema, we will use @rjsf/core and @rjsf/validator-ajv8.

First, install the dependencies:

npm install @rjsf/core @rjsf/validator-ajv8

Next, create the dynamic form component. This working RJSF-based renderer consumes the schema above, handles dynamic data sources, and cascades correctly when parent fields change.

import Form from '@rjsf/core';
import validator from '@rjsf/validator-ajv8';
import { useEffect, useState } from 'react';
import type { RJSFSchema, WidgetProps } from '@rjsf/utils';
 
type DataSource = {
  endpoint: string;
  valueKey: string;
  labelKey: string;
  dependsOn?: string[];
};
 
// Custom hook to fetch live configuration options
function useDataSource(source: DataSource | undefined, formData: any) {
  const [options, setOptions] = useState<{ value: string; label: string }[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
 
  const depsReady = !source?.dependsOn?.length
    || source.dependsOn.every((k) => formData?.[k]);
 
  const depsKey = JSON.stringify(
    source?.dependsOn?.map((k) => formData?.[k]) ?? []
  );
 
  useEffect(() => {
    if (!source || !depsReady) return;
    let cancelled = false;
    setLoading(true);
    setError(null);
 
    const url = new URL(source.endpoint, window.location.origin);
    source.dependsOn?.forEach((k) => url.searchParams.set(k, formData[k]));
 
    fetch(url.toString())
      .then(async (res) => {
        if (res.status === 429) {
          const reset = res.headers.get('ratelimit-reset');
          throw new Error(`Rate limited. Retry after ${reset}s.`);
        }
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((rows: any[]) => {
        if (cancelled) return;
        setOptions(rows.map((r) => ({
          value: r[source.valueKey],
          label: r[source.labelKey],
        })));
      })
      .catch((e) => !cancelled && setError(e.message))
      .finally(() => !cancelled && setLoading(false));
 
    return () => { cancelled = true; };
  }, [source?.endpoint, depsKey, depsReady]);
 
  return { options, loading, error };
}
 
// Custom Widget to render dynamic selects
function DynamicSelect(props: WidgetProps) {
  const source = props.schema['x-dataSource'] as DataSource | undefined;
  const { options, loading, error } = useDataSource(source, props.formContext?.formData);
 
  if (!source) return null;
 
  return (
    <div className="dynamic-select-container">
      <select
        value={props.value ?? ''}
        disabled={loading || !!error}
        onChange={(e) => props.onChange(e.target.value)}
        className="form-control"
      >
        <option value="">{loading ? 'Loading...' : 'Select...'}</option>
        {options.map((o) => (
          <option key={o.value} value={o.value}>{o.label}</option>
        ))}
      </select>
      {error && <p className="error-text" style={{ color: 'crimson' }}>{error}</p>}
    </div>
  );
}
 
// Main Form Component
export function IntegrationSetupForm({ schema, onSubmitSuccess }: {
  schema: RJSFSchema;
  onSubmitSuccess: () => void;
}) {
  const [formData, setFormData] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [submitError, setSubmitError] = useState<string | null>(null);
 
  const widgets = { DynamicSelect };
  const uiSchema = buildUiSchema(schema);
 
  const handleSubmit = async ({ formData }: any) => {
    setIsSubmitting(true);
    setSubmitError(null);
 
    try {
      const response = await fetch('/api/integrations/configure', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ config: formData }),
      });
 
      if (!response.ok) throw new Error('Failed to save configuration');
      onSubmitSuccess();
    } catch (err: any) {
      setSubmitError(err.message);
    } finally {
      setIsSubmitting(false);
    }
  };
 
  return (
    <div className="integration-form-wrapper">
      {submitError && <div className="alert alert-danger">{submitError}</div>}
      <Form
        schema={schema}
        uiSchema={uiSchema}
        widgets={widgets}
        formData={formData}
        formContext={{ formData }}
        validator={validator}
        onChange={(e) => setFormData(e.formData)}
        onSubmit={handleSubmit}
        disabled={isSubmitting}
      >
        <button type="submit" disabled={isSubmitting} className="btn btn-primary">
          {isSubmitting ? 'Saving...' : 'Save Configuration'}
        </button>
      </Form>
    </div>
  );
}
 
// Helper to map x-dataSource to our custom widget
function buildUiSchema(schema: RJSFSchema, acc: any = {}): any {
  const props = (schema as any).properties ?? {};
  for (const [key, sub] of Object.entries<any>(props)) {
    if (sub['x-dataSource']) acc[key] = { 'ui:widget': 'DynamicSelect' };
    if (sub.type === 'object') acc[key] = buildUiSchema(sub, acc[key] ?? {});
  }
  return acc;
}

This is roughly 120 lines of code that renders any integration's setup form as long as the backend produces a valid schema. Adding Notion, Jira, or Zendesk becomes a schema change on the server, not a frontend release.

Handling Dynamic Data Fetching and Rate Limits

The part of this architecture that always bites teams is populating dropdowns from live upstream APIs. "Show me my Salesforce record types" means a live call to Salesforce, on every setup session, for every customer.

For a deep dive into building these data pipelines, refer to our guide on Dynamic Post-Connection Configuration. Three hard problems live here:

1. Cascading Fetches

When a user picks a workspace, you need to fetch projects. When they pick a project, you might need to fetch sections. Each of these is a network call, and each is invalidated when the parent changes. The useDataSource hook above handles this by re-running when the dependsOn values change and clearing stale results with cancelled flags to avoid race conditions.

2. Token Freshness & Security

By the time the user opens the config screen, the OAuth access token from twenty minutes ago may already be expired. Every dynamic endpoint call needs a valid token. Refreshing tokens inline ("call the API, get a 401, refresh, retry") is expensive and races itself under concurrent form loads.

The cleaner pattern is to refresh proactively, shortly before expiry, and keep the token warm for any UI that needs it.

Furthermore, never expose OAuth tokens to the frontend just to populate dropdowns. If your React app needs to fetch Asana projects, passing the raw Asana access token to the browser is a massive security vulnerability. Your backend should act as a proxy, holding the token securely, making the request to Asana, and returning only the clean JSON array of options to the frontend.

3. Upstream Rate Limits

Upstream APIs will 429 you. Salesforce, HubSpot, and Jira all enforce per-org limits that a busy customer's setup session can trivially blow through—especially if you fetch workspaces, projects, custom fields, and record types on the same screen.

When building integration infrastructure, you must treat rate limits as an expected state, not an exception. If you are using Truto to manage your integrations, it is critical to understand that Truto does not absorb, retry, or throttle upstream 429s automatically. When the vendor returns a rate limit error, Truto passes that status through to your caller.

What Truto does do is normalize the upstream rate limit metadata into standardized IETF headers—ratelimit-limit, ratelimit-remaining, and ratelimit-reset—regardless of what oddball header format the vendor used. That means your backend can implement one consistent exponential backoff policy across every connector.

A minimal backoff pattern looks like this:

async function fetchDynamicOptionsWithBackoff(url: string, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const response = await fetch(url);
 
    if (response.status === 429) {
      const resetTime = response.headers.get('ratelimit-reset');
      
      if (resetTime) {
        // Calculate seconds to wait based on IETF standard header
        const now = Math.floor(Date.now() / 1000);
        const waitSeconds = parseInt(resetTime, 10) - now;
        
        if (waitSeconds > 0 && waitSeconds < 60) {
          console.warn(`Rate limited. Waiting ${waitSeconds}s...`);
          await new Promise(res => setTimeout(res, waitSeconds * 1000));
          continue; // Retry the request
        }
      }
      
      // Fallback exponential backoff if reset time is missing or too long
      const backoff = Math.pow(2, attempt) * 1000;
      await new Promise(res => setTimeout(res, backoff));
      continue;
    }
 
    if (!response.ok) {
      throw new Error(`API Error: ${response.status}`);
    }
 
    return await response.json();
  }
  
  throw new Error('Max retries exceeded while fetching schema options');
}

Simplifying Post-Connection UI with Truto RapidForm

Building the entire pipeline—managing OAuth state, securely storing tokens, handling cascading fetches, writing backoff utilities for 429s, generating JSON schemas dynamically, and rendering React forms—requires significant engineering investment.

The question is whether you want to own this infrastructure for the next fifty connectors.

Truto RapidForm collapses the entire pattern into a declarative config. You define the form once as JSON, reference upstream endpoints for dynamic options, and Truto handles the rendering, cascading, token management, and rate limit surfacing.

A rough shape of what a RapidForm config looks like:

{
  "fields": [
    {
      "key": "workspaceId",
      "label": "Workspace",
      "type": "select",
      "options": {
        "source": "https://app.asana.com/api/1.0/workspaces",
        "headers": { "Authorization": "Bearer {{oauth.token.access_token}}" },
        "mapping": "data.{ 'value': gid, 'label': name }"
      }
    },
    {
      "key": "projectIds",
      "label": "Projects",
      "type": "multi-select",
      "dependsOn": ["workspaceId"],
      "options": {
        "source": "https://app.asana.com/api/1.0/workspaces/{{form.workspaceId}}/projects",
        "headers": { "Authorization": "Bearer {{oauth.token.access_token}}" },
        "mapping": "data.{ 'value': gid, 'label': name }"
      }
    }
  ]
}

Secure Context Variables

Notice the {{oauth.token.access_token}} syntax. Truto isolates data produced during authentication into secure Context Variables.

You can use these variables directly within JSONata expressions in your RapidForm configuration to fetch live data. Truto evaluates these expressions on the server, makes the API call to the provider, handles the proactive token refresh, and returns only the populated JSON schema to the frontend. The access token never touches the client.

To see how this works in practice with complex configurations like recursive block fetching, read our teardown of How Truto Helps Engineers Build Faster Integrations - Notion.

Where to Take This Next

If you take one thing from this guide: stop hardcoding integration setup screens. Every hardcoded form is a future migration and a primary driver of activation drop-off. Move the schema to the server, keep the renderer generic, and treat dynamic option sources as first-class citizens.

A reasonable path forward:

  1. Pick a schema standard. JSON Schema is the pragmatic default. Add small x-* extensions for what it doesn't cover.
  2. Ship a single renderer. RJSF or a fork of the code above. Resist the urge to write a second one.
  3. Solve token freshness at the platform layer. Setup UIs cannot afford inline refresh races, and passing raw tokens to the frontend is a security risk.
  4. Standardize rate limit handling. One retry policy across all connectors. IETF headers make this trivial.
  5. Measure activation. Instrument every field in the form. If nobody ever changes syncMode from its default, drop it.

Once you have this loop working, adding a new integration stops being a two-week engineering project. It becomes a config PR reviewed over lunch.

FAQ

What is post-connection configuration in SaaS?
Post-connection configuration is the setup phase immediately following a successful OAuth authentication. It involves users selecting workspaces, mapping custom fields, and configuring sync settings to make the integration functional.
Why use JSON Schema instead of hardcoded React forms for integration setup?
Hardcoded forms mean every new integration is a frontend deploy. Using JSON Schema allows the backend to dictate the structure, validation, and dynamic options of a form. This prevents frontend engineers from having to write bespoke React components every time configuration requirements change.
How do you handle rate limits when fetching dynamic form options?
When fetching live data (like workspaces or custom fields) to populate form dropdowns, your backend must implement exponential backoff. Look for standardized IETF headers like `ratelimit-reset` to calculate exactly how long to wait before retrying.
Is it safe to pass OAuth tokens to the frontend to fetch form data?
No, passing raw OAuth tokens to the client is a major security risk. You should use server-side context variables to fetch dynamic options and only pass the populated JSON schema or clean data arrays to the frontend.
Can I use React JSON Schema Form (RJSF) for integration configuration UIs?
Yes. RJSF is the most widely adopted open-source JSON Schema renderer for React. You will typically add a custom widget for dynamic remote-fetched dropdowns and cascading selects, since JSON Schema itself does not standardize how to source remote options.

More from our Blog