A React contact form is easy until it needs to be changed by someone who does
not ship the frontend.
Then the project needs a schema, an editor, server validation, spam controls,
submission storage and a place to review what arrived. That work usually ends
up split between a custom Strapi content type, a controller, an admin page and
the React component that started the whole thing.
This tutorial uses FormFlow to keep the form definition in Strapi v5 while
leaving the rendered UI in React.
The data flow is small:
Strapi admin builder
↓
GET /api/formflow/forms/:slug
↓
Your React components
↓
POST /api/formflow/forms/:slug/submit
↓
Strapi submission inbox
There is no iframe. The React SDK ships no CSS.
Install the Strapi plugin
In the Strapi v5 project:
npm install @formflowjs/strapi-plugin-formflow
Enable it in config/plugins.ts:
export default {
formflow: {
enabled: true,
},
};
Rebuild and start the project:
npm run build
npm run develop
Open FormFlow from the admin sidebar. Create a form with the slug
contact-form, then add required name, email and message fields.
Activate the form and inspect the public schema:
curl http://localhost:1337/api/formflow/forms/contact-form
FormFlow returns the public field model and settings under data. Server-only
configuration, including captcha secrets, is not part of that projection.
Install the headless React SDK
In the frontend:
npm install @formflowjs/react @formflowjs/core
Set the Strapi origin:
VITE_CMS_URL=http://localhost:1337
Create the API client and fetch the schema:
import { useEffect, useState } from "react";
import { createFormFlowClient, type FormSchema } from "@formflowjs/core";
const client = createFormFlowClient({
baseUrl: import.meta.env.VITE_CMS_URL,
});
export function ContactPage() {
const [schema, setSchema] = useState<FormSchema | null>(null);
useEffect(() => {
client.getForm("contact-form").then(setSchema);
}, []);
if (!schema) return <p>Loading form…</p>;
return <ContactForm schema={schema} />;
}
For Next.js or Astro, fetch the same schema on the server and pass it to the
interactive client component.
Bind the schema to your own markup
FormFlowProvider owns the form store. FormFlowField connects one field to
your input. The prop getters wire values, events, labels, descriptions, errors
and ARIA attributes.
import {
FormFlowField,
FormFlowHoneypot,
FormFlowProvider,
useFormFlow,
type FormSchema,
} from "@formflowjs/react";
function FormBody() {
const form = useFormFlow();
if (form.status === "success") {
return <p role="status">{form.result?.message}</p>;
}
return (
<form {...form.getFormProps()}>
<FormFlowHoneypot />
{form.fields.map((field) => (
<FormFlowField key={field.name} name={field.name}>
{(control) => (
<div data-invalid={control.invalid || undefined}>
<label {...control.getLabelProps()}>{control.field.label}</label>
{control.field.type === "textarea" ? (
<textarea {...control.getTextareaProps()} rows={5} />
) : (
<input {...control.getInputProps()} />
)}
{control.invalid && (
<p {...control.getErrorProps()}>{control.error}</p>
)}
</div>
)}
</FormFlowField>
))}
<button type="submit" disabled={form.isSubmitting}>
{form.isSubmitting
? "Submitting…"
: form.schema.settings.submitButtonText}
</button>
</form>
);
}
export function ContactForm({ schema }: { schema: FormSchema }) {
return (
<FormFlowProvider
form={schema}
baseUrl={import.meta.env.VITE_CMS_URL}
options={{ validateOn: "blur" }}
>
<FormBody />
</FormFlowProvider>
);
}
This example handles text-like fields and textareas. A full renderer should use
the SDK's select, checkbox, radio and file prop getters, and treat headings,
paragraphs and dividers as display-only fields.
The important boundary is that the SDK does not choose the component. It gives
your component the behavior and accessibility contract.
Test the server path, not only the browser
Submit once with an empty required field. The client should display an error
without sending invalid data.
Then submit a valid response and open the FormFlow submission inbox in Strapi.
The stored entry should contain the submitted field map.
Client validation is feedback, not trust. On final submission the plugin reads
the current form schema, drops hidden fields, validates the remaining values
and files, and only then stores the entry.
When this approach fits
A headless form contract is useful when:
- editors need to change fields without a frontend release;
- the frontend must use an existing design system;
- submission data should remain in Strapi;
- the same schema may be rendered by more than one frontend;
- forms need operational behavior beyond a POST endpoint.
It is unnecessary if the project has one permanent form and nobody needs an
admin workflow. A hard-coded component and small controller can still be the
right answer.
FormFlow is open-core. Its MIT-licensed free core includes unlimited forms and
submissions, standard fields, file uploads, validation, the inbox, CSV/JSON
export, basic spam controls, email, RBAC, REST and SDK compatibility. Some
advanced workflows are licensed separately under the repository's ee/
directories.
Resources:
Top comments (0)