Skip to main content

React JS
LitePro
Improved in 5.16+

Use the official @solspace/freeform-react packages. See Getting Started for enabling the headless API. For Vue 3, see Vue.js.

Install

npm install @solspace/freeform-core \
@solspace/freeform-react \
@solspace/freeform-extensions \
@solspace/freeform-theme-default

Import the default theme CSS once in your app entry:

import '@solspace/freeform-theme-default/styles.css';

Render with <Freeform />Recommended

The easiest path — Freeform loads the manifest, manages state, renders fields, handles CSRF, and submits:

ContactForm.tsx
import { Freeform } from '@solspace/freeform-react';
import { recommendedExtensions } from '@solspace/freeform-extensions';
import '@solspace/freeform-theme-default/styles.css';

export function ContactForm() {
return (
<Freeform
handle="contact"
baseUrl="https://cms.example.com"
extensions={recommendedExtensions}
loadingMessage="Loading form…"
onSuccess={(response) => {
// response.success, response.submission, etc.
}}
onError={(response) => {
// Field / form errors from Freeform
}}
/>
);
}
PropDescription
handleForm handle (must be allowed in headless.forms)
baseUrlCraft origin. Use "" or your SPA origin when /freeform is proxied.
extensionsCaptcha / datetime / file-dnd / calculation / table / signature / Stripe
draftToken / draftKeyResume a saved draft (from a prior saveDraft response)
themeOptional theme object (lightTheme, darkTheme, or createTheme())
allowRawHtmlDefault false. Set true only if HTML/rich-text fields are trusted CMS content.
loadingFallback / errorFallbackCustom loading and error UI

Light / Dark Theme

import { Freeform } from '@solspace/freeform-react';
import {
darkTheme,
lightTheme,
} from '@solspace/freeform-theme-default';
import '@solspace/freeform-theme-default/styles.css';

<Freeform handle="contact" baseUrl="" theme={darkTheme} />;
// or theme={lightTheme}

By default the theme follows the visitor’s OS preference (system).

Tailwind Theme

If the app already uses Tailwind, use the official Tailwind starter theme. It ships no CSS — your Tailwind build generates the utilities.

npm install @solspace/freeform-theme-tailwind
app.css
@import "tailwindcss";
@source "../node_modules/@solspace/freeform-theme-tailwind";
import { Freeform } from '@solspace/freeform-react';
import { tailwindTheme } from '@solspace/freeform-theme-tailwind';

<Freeform handle="contact" baseUrl="" theme={tailwindTheme} />;

Use tailwindDarkTheme on a dark page. Do not import @solspace/freeform-theme-default/styles.css on the same form. Override slots with createTheme({ classNames: { submitButton: "…" } }) from the Tailwind package.

See the package README for Tailwind 3 content paths.

Bootstrap Theme

If the app already uses Bootstrap 5, use the official Bootstrap starter theme. It ships no CSS — load Bootstrap in your app.

npm install @solspace/freeform-theme-bootstrap bootstrap
import 'bootstrap/dist/css/bootstrap.min.css';
import '@solspace/freeform-theme-bootstrap/styles.css';
import { Freeform } from '@solspace/freeform-react';
import { bootstrapTheme } from '@solspace/freeform-theme-bootstrap';

<Freeform handle="contact" baseUrl="" theme={bootstrapTheme} />;

Use bootstrapDarkTheme on a dark page (classic bootstrap-5-dark). Do not import @solspace/freeform-theme-default/styles.css on the same form. Override slots with createTheme() from the Bootstrap package.

Headless control with useFreeform()

Own the markup while Freeform still loads the form, tracks values, evaluates conditionals, and submits:

HeadlessContactForm.tsx
import { useFreeform } from '@solspace/freeform-react';
import { recommendedExtensions } from '@solspace/freeform-extensions';

export function HeadlessContactForm() {
const form = useFreeform({
handle: 'contact',
baseUrl: 'https://cms.example.com',
extensions: recommendedExtensions,
});

if (form.loading) {
return <p>Loading…</p>;
}

if (form.error) {
return <p role="alert">{form.error.message}</p>;
}

return (
<form onSubmit={form.handleSubmit}>
{form.formErrors.map((message) => (
<div key={message} role="alert">
{message}
</div>
))}

<label>
Email
<input {...form.getFieldProps('email')} type="email" />
</label>

<button type="submit" disabled={form.isSubmitting}>
{form.isSubmitting ? 'Submitting…' : 'Submit'}
</button>

{form.isComplete && form.successMessage ? (
<p>{form.successMessage}</p>
) : null}
</form>
);
}

Useful helpers on the hook result:

APIPurpose
getFieldProps(handle)name, id, onChange, onBlur, etc.
values / setValueRead / write field values
fieldErrors / formErrorsValidation messages
isFieldVisible(handle)Conditional visibility
handleSubmit / goNext / goBack / saveDraftSubmit, multi-page, and save progress

Save & Continue Later

Enable Save on the form’s Button Layout in Freeform. React shows the Save button automatically.

After Save, Freeform returns a token and key. Put those in your page URL so the visitor can come back later (email the link, bookmark it, or copy it):

ContactFormWithSave.tsx
import { Freeform } from '@solspace/freeform-react';
import { recommendedExtensions } from '@solspace/freeform-extensions';

function readDraft() {
const params = new URLSearchParams(window.location.search);
return {
draftToken: params.get('session-token'),
draftKey: params.get('key'),
};
}

function writeDraft(token: string, key: string) {
const url = new URL(window.location.href);
url.searchParams.set('session-token', token);
url.searchParams.set('key', key);
window.history.replaceState({}, '', url);
}

export function ContactFormWithSave() {
const { draftToken, draftKey } = readDraft();

return (
<Freeform
handle="contact"
baseUrl="https://cms.example.com"
extensions={recommendedExtensions}
draftToken={draftToken}
draftKey={draftKey}
onSuccess={(response) => {
if (
response.status === 'draft_saved' &&
response.draft?.token &&
response.draft?.key
) {
writeDraft(response.draft.token, response.draft.key);
// Optional: show “Progress saved — bookmark this page”
}
}}
/>
);
}

Customer Flow

  1. Enable Save in Freeform → set Save label (Redirect URL optional).
  2. Visitor clicks Save → your app stores token + key in the URL (as above).
  3. Visitor returns via that URL → pass draftToken / draftKey → fields restore.
  4. Visitor clicks Submit → real submission; draft is removed.

Optional: set the form’s Save Redirect URL to https://your-app.com/contact?session-token={token}&key={key} so the API also returns draft.resumeUrl for emails or custom UI.

Captchas and Advanced Fields

Pass recommendedExtensions (or a subset) so Freeform can mount captchas and advanced fields:

import {
captchaExtensions,
datetimeExtension,
fileDndExtension,
calculationExtension,
tableExtension,
signatureExtension,
stripePaymentExtension,
squarePaymentExtension,
paypalPaymentExtension,
molliePaymentExtension,
recommendedExtensions,
} from '@solspace/freeform-extensions';

// Recommended preset (captchas + datetime + file DnD + calculation + table + signature + Stripe + Square + PayPal + Mollie):
<Freeform extensions={recommendedExtensions}/>

// Or pick what you need:
<Freeform
extensions={[
...captchaExtensions,
datetimeExtension,
tableExtension,
signatureExtension,
stripePaymentExtension,
squarePaymentExtension,
paypalPaymentExtension,
molliePaymentExtension,
]}

/>
ExtensionCovers
CaptchasTurnstile, reCAPTCHA, hCaptcha, Friendly Captcha
datetimeExtensionFlatpickr / native datetime fields
fileDndExtensionFile Drag & Drop uploads
calculationExtensionLive calculation fields
tableExtensionTable rows (min/max/exact limits, required columns, file cells)
signatureExtensionSignature pad (draw, clear, required validation)
stripePaymentExtensionStripe Payment Element (one-time and subscriptions)
squarePaymentExtensionSquare Web Payments SDK (one-time card payments)
paypalPaymentExtensionPayPal Buttons (PayPal wallet, Venmo, Pay Later, debit/credit card)
molliePaymentExtensionMollie hosted checkout redirect (iDEAL, Bancontact, cards, and more)

Configure captcha integrations in the Freeform control panel. Site keys are exposed on the form manifest; tokens are submitted automatically through meta.captchas.

For table fields: enable the field in Freeform as usual. Row limits and required columns come from the form builder. File columns upload through the same headless multipart path as standalone file fields.

For signature fields: visitors draw on the canvas; the value is stored as a PNG data URL. Clear resets the pad.

Payments

Stripe works in headless React the same way it does in classic Freeform templates: configure the Stripe Payments integration and add a Stripe field in the form builder. With recommendedExtensions (or stripePaymentExtension), Freeform mounts Stripe’s Payment Element from the form manifest.

DonationForm.tsx
import { Freeform } from '@solspace/freeform-react';
import { recommendedExtensions } from '@solspace/freeform-extensions';
import '@solspace/freeform-theme-default/styles.css';

export function DonationForm() {
return (
<Freeform
handle="donation"
baseUrl="" // same-origin proxy recommended
extensions={recommendedExtensions}
/>
);
}

What Freeform handles for you

  • Manifest exposes public Stripe config only (publishable key, theme/layout, amount field handles, opaque integration hash). Secret keys never leave Craft.
  • Payment Element creates / updates PaymentIntents through Freeform’s existing Stripe routes.
  • Dynamic amounts sync from your Number (or other amount) fields before confirmation.
  • On final submit, Freeform validates the form, stores an encrypted payment checkpoint, confirms with Stripe (including 3DS when required), then completes via Freeform’s Stripe callback / webhook — the same finalization path as classic forms.

Requirements

  • Freeform Pro with Stripe configured (test or live keys + webhook)
  • Headless enabled for the form (exposeManifest + allowSubmit)
  • @solspace/freeform-extensions that includes stripePaymentExtension (use recommendedExtensions)
  • Prefer a same-origin proxy for /freeform/* so CSRF cookies and Stripe endpoints stay on your app origin

Multi-page forms: put the Stripe field on the last page. Back / Next never charge; payment runs only on final submit.

Testing cards (Stripe test mode): success 4242…, decline 4000000000000002, 3DS 4000000000003220. Full list: Stripe testing and Stripe Payments → Testing.

Custom Field Renderers

Override rendering by handle, frontend renderer key, or field type:

<Freeform
handle="donation"
baseUrl="https://cms.example.com"
renderers={{
handles: {
payment: MyPaymentField,
},
types: {
text: MyTextField,
},
}}
/>

Same-origin ProxyRecommended

Point your Vite (or other) dev server at Craft so CSRF cookies stay same-origin:

vite.config.ts
export default {
server: {
proxy: {
'/freeform': {
target: 'https://cms.example.com',
changeOrigin: true,
secure: false, // local TLS only
},
},
},
};

Then use baseUrl="" (or window.location.origin) in the browser.

For Next.js, see the Next.js guide.

Security Notes

  • Leave allowRawHtml at the default (false) unless HTML fields are trusted.
  • Enable a captcha on public forms.
  • Set headless.allowedOrigins when calling Craft cross-origin.
  • See REST API for CORS and CSRF details.

Example Demo

GraphQL

Prefer the headless GraphQL adapters when you want Craft GraphQL with the same manifest/submit contract as REST. Legacy form queries and save_{handle}_Submission mutations are documented on the same page.

Legacy GraphQL / AJAX Demos

Older demos that query Freeform via GraphQL or custom AJAX still work, but are not the recommended path for new projects:

Please note that the paths in the demos are specific for the Solspace demo site and server. Please make sure your code uses paths that match your server setup.