End-to-End Integration
Fetch a form from the Formidable API, render it in React, and submit data — all in one page.
- A React + TypeScript project (Next.js, Vite, or similar)
- A Formidable account with an API key
- A published form (create one in the Formidable app)
1. Install Packages
- npm
- Yarn
- pnpm
- Bun
npm install @fogpipe/forma-react @fogpipe/forma-core
yarn add @fogpipe/forma-react @fogpipe/forma-core
pnpm add @fogpipe/forma-react @fogpipe/forma-core
bun add @fogpipe/forma-react @fogpipe/forma-core
@fogpipe/forma-core provides TypeScript types and the FEEL expression engine. @fogpipe/forma-react provides FormRenderer, the useForma hook, and component type definitions.
2. Fetch the Form
Call the Formidable API to retrieve your published form's specification.
import type { Forma } from "@fogpipe/forma-core";
const API_BASE = "https://api.formidable.software/v1";
export async function fetchForm(slug: string): Promise<Forma> {
const res = await fetch(`${API_BASE}/forms/${slug}`, {
headers: {
Authorization: `Bearer ${process.env.FORMIDABLE_API_KEY}`,
},
});
if (!res.ok) {
throw new Error(`Failed to fetch form: ${res.status}`);
}
const json = await res.json();
return json.formspec as Forma; // The spec is nested under `formspec`
}
The API returns { id, slug, title, formspec, ... } — not the Forma spec directly. Always extract json.formspec before passing it to FormRenderer.
3. Build a Minimal ComponentMap
You can skip this step entirely with Default Components:
import { DefaultFormRenderer } from "@fogpipe/forma-react/defaults";
import "@fogpipe/forma-react/defaults/styles.css";
<DefaultFormRenderer spec={spec} onSubmit={handleSubmit} />;
The rest of this section shows how to build your own components for full control.
You only need 4 components + a fallback to render most forms. Start here and add more field types as you need them.
import type {
ComponentMap,
TextComponentProps,
NumberComponentProps,
BooleanComponentProps,
SelectComponentProps,
} from "@fogpipe/forma-react";
function TextInput({ field }: TextComponentProps) {
return (
<div style={{ marginBottom: 16 }}>
<label htmlFor={field.name}>
{field.label} {field.required && "*"}
</label>
<input
id={field.name}
type={field.fieldType === "email" ? "email" : "text"}
value={field.value ?? ""}
onChange={(e) => field.onChange(e.target.value)}
onBlur={field.onBlur}
placeholder={field.placeholder}
disabled={!field.enabled}
/>
{field.touched &&
field.errors.map((err, i) => (
<p key={i} style={{ color: "red" }}>
{err.message}
</p>
))}
</div>
);
}
function NumberInput({ field }: NumberComponentProps) {
return (
<div style={{ marginBottom: 16 }}>
<label htmlFor={field.name}>
{field.label} {field.required && "*"}
</label>
<input
id={field.name}
type="number"
value={field.value ?? ""}
onChange={(e) =>
field.onChange(e.target.value === "" ? null : Number(e.target.value))
}
onBlur={field.onBlur}
min={field.min}
max={field.max}
step={field.step}
disabled={!field.enabled}
/>
{field.touched &&
field.errors.map((err, i) => (
<p key={i} style={{ color: "red" }}>
{err.message}
</p>
))}
</div>
);
}
function Checkbox({ field }: BooleanComponentProps) {
return (
<div style={{ marginBottom: 16 }}>
<label>
<input
type="checkbox"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
onBlur={field.onBlur}
disabled={!field.enabled}
/>{" "}
{field.label}
</label>
{field.touched &&
field.errors.map((err, i) => (
<p key={i} style={{ color: "red" }}>
{err.message}
</p>
))}
</div>
);
}
function SelectInput({ field }: SelectComponentProps) {
return (
<div style={{ marginBottom: 16 }}>
<label htmlFor={field.name}>
{field.label} {field.required && "*"}
</label>
<select
id={field.name}
value={field.value ?? ""}
onChange={(e) =>
field.onChange(e.target.value === "" ? null : e.target.value)
}
onBlur={field.onBlur}
disabled={!field.enabled}
>
<option value="">{field.placeholder || "Select..."}</option>
{field.options.map((opt) => (
<option key={String(opt.value)} value={opt.value}>
{opt.label}
</option>
))}
</select>
{field.touched &&
field.errors.map((err, i) => (
<p key={i} style={{ color: "red" }}>
{err.message}
</p>
))}
</div>
);
}
export const components: ComponentMap = {
text: TextInput,
number: NumberInput,
integer: NumberInput, // Reuse NumberInput for integers
boolean: Checkbox,
select: SelectInput,
fallback: TextInput, // Catch-all for unmapped types (email, phone, url, etc.)
};
The fallback key catches any unmapped field type and renders it as a text input. This means text, email, phone, url, and textarea all work out of the box. You can add specialized components later — see Component Mapping for the full reference.
4. Render the Form
import { useState, useEffect } from "react";
import { FormRenderer } from "@fogpipe/forma-react";
import type { Forma } from "@fogpipe/forma-core";
import { components } from "./components";
import { fetchForm } from "./api";
function App() {
const [spec, setSpec] = useState<Forma | null>(null);
const [result, setResult] = useState<Record<string, unknown> | null>(null);
useEffect(() => {
fetchForm("your-form-slug").then(setSpec);
}, []);
if (!spec) return <p>Loading form...</p>;
return (
<div style={{ maxWidth: 480, margin: "0 auto", padding: 32 }}>
<h1>{spec.meta.title}</h1>
<FormRenderer
spec={spec}
components={components}
onSubmit={(data) => {
console.log("Submitted:", data);
setResult(data);
}}
/>
{result && (
<pre style={{ marginTop: 24, background: "#f5f5f5", padding: 16 }}>
{JSON.stringify(result, null, 2)}
</pre>
)}
</div>
);
}
export default App;
That's it. Run your dev server, and you should see the form rendered with your components.
5. Submit to the API
To persist submissions in Formidable (so they appear in the dashboard and trigger webhooks), POST the data back:
export async function submitForm(slug: string, data: Record<string, unknown>) {
const res = await fetch(`${API_BASE}/forms/${slug}/submit`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FORMIDABLE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ data }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.errors?.[0]?.message ?? "Submission failed");
}
return res.json(); // { submissionId, computedOutputs? }
}
Then update your onSubmit handler:
<FormRenderer
spec={spec}
components={components}
onSubmit={async (data) => {
const result = await submitForm("your-form-slug", data);
console.log("Submission ID:", result.submissionId);
// computedOutputs includes label and format for each output
// e.g. { monthlyPayment: { value: 1250, label: "Monthly Payment", format: "currency" } }
if (result.computedOutputs) {
for (const [key, output] of Object.entries(result.computedOutputs)) {
console.log(`${output.label}: ${output.value}`);
}
}
}}
/>
Common Gotchas
Extract formspec from the API response
The API returns { formspec: ... }, not the spec directly. Passing the full response object to FormRenderer will silently fail.
Gate error display on field.touched
Forma evaluates all validation rules immediately, so field.errors may be populated before the user interacts with the field. Always check field.touched before displaying errors to avoid showing a wall of red on load.
Prefer server-side API calls
Your API key (fmb_live_*) is a secret. Fetch the form spec from your own backend, not directly from the browser. The examples above use process.env.FORMIDABLE_API_KEY which should be server-side only.
Next Steps
- Component Mapping — Add more field types (date, multiselect, array, matrix)
- Styling Guide — Style your components with Tailwind, CSS Modules, or plain CSS
- useForma Hook — Build fully custom form UIs
- Wizard Forms — Handle multi-page forms
- Webhooks — Get notified when submissions arrive