Vue.jsLiteProImproved in 5.16+
Use the official @solspace/freeform-vue packages. See Getting Started for enabling the headless API.
The Vue adapter matches the React API (<Freeform />, useFreeform(), themes, extensions, renderers). Prefer Vue SFCs and slots instead of React children / fallbacks.
Install
npm install @solspace/freeform-core \
@solspace/freeform-vue \
@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:
<script setup lang="ts">
import { Freeform } from '@solspace/freeform-vue';
import { recommendedExtensions } from '@solspace/freeform-extensions';
import '@solspace/freeform-theme-default/styles.css';
const baseUrl = 'https://cms.example.com';
function onSuccess(response: { success?: boolean; submission?: unknown }) {
// response.success, response.submission, etc.
}
function onError(response: unknown) {
// Field / form errors from Freeform
}
</script>
<template>
<Freeform
handle="contact"
:base-url="baseUrl"
:extensions="recommendedExtensions"
loading-message="Loading form…"
:on-success="onSuccess"
:on-error="onError"
/>
</template>
| Prop | Description |
|---|---|
handle | Form handle (must be allowed in headless.forms) |
baseUrl | Craft origin. Use "" or your SPA origin when /freeform is proxied. |
extensions | Captcha / datetime / file-dnd / calculation / table / signature / Stripe |
draftToken / draftKey | Resume a saved draft (from a prior saveDraft response) |
theme | Optional theme object (lightTheme, darkTheme, or createTheme()) |
allowRawHtml | Default false. Set true only if HTML/rich-text fields are trusted CMS content. |
#loading / #error slots | Custom loading and error UI |
#default="{ form }" | Headless slot — own the markup (same idea as React children) |
Light / Dark Theme
<script setup lang="ts">
import { Freeform } from '@solspace/freeform-vue';
import { darkTheme, lightTheme } from '@solspace/freeform-theme-default';
import '@solspace/freeform-theme-default/styles.css';
</script>
<template>
<Freeform handle="contact" base-url="…" :theme="darkTheme" />
<!-- or :theme="lightTheme" -->
</template>
By default the theme follows the visitor’s OS preference (system).
@solspace/freeform-theme-default, -tailwind, and -bootstrap are shared with React. Pass them as :theme on Vue forms the same way.
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
@import "tailwindcss";
@source "../node_modules/@solspace/freeform-theme-tailwind";
<script setup lang="ts">
import { Freeform } from '@solspace/freeform-vue';
import { tailwindTheme } from '@solspace/freeform-theme-tailwind';
</script>
<template>
<Freeform handle="contact" base-url="…" :theme="tailwindTheme" />
</template>
Use tailwindDarkTheme on a dark page. Do not import @solspace/freeform-theme-default/styles.css on the same form.
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';
<script setup lang="ts">
import { Freeform } from '@solspace/freeform-vue';
import { bootstrapTheme } from '@solspace/freeform-theme-bootstrap';
</script>
<template>
<Freeform handle="contact" base-url="…" :theme="bootstrapTheme" />
</template>
Use bootstrapDarkTheme on a dark page. Do not import @solspace/freeform-theme-default/styles.css on the same form.
Headless Control with useFreeform()
Own the markup while Freeform still loads the form, tracks values, evaluates conditionals, and submits:
<script setup lang="ts">
import { useFreeform } from '@solspace/freeform-vue';
import { recommendedExtensions } from '@solspace/freeform-extensions';
const form = useFreeform({
handle: 'contact',
baseUrl: 'https://cms.example.com',
extensions: recommendedExtensions,
});
function onSubmit(event: Event) {
void form.handleSubmit(event);
}
</script>
<template>
<p v-if="form.loading">Loading…</p>
<p v-else-if="form.error" role="alert">{{ form.error.message }}</p>
<form v-else @submit="onSubmit">
<div
v-for="message in form.formErrors"
:key="message"
role="alert"
>
{{ message }}
</div>
<label>
Email
<input v-bind="form.getFieldProps('email')" type="email" />
</label>
<button type="submit" :disabled="form.isSubmitting">
{{ form.isSubmitting ? 'Submitting…' : 'Submit' }}
</button>
<p v-if="form.isComplete && form.successMessage">
{{ form.successMessage }}
</p>
</form>
</template>
You can also pass a default slot to <Freeform>:
<Freeform handle="contact" :base-url="baseUrl">
<template #default="{ form }">
<!-- your markup using form.* -->
</template>
</Freeform>
Useful helpers on the composable result:
| API | Purpose |
|---|---|
getFieldProps(handle) | name, id, onChange, onBlur, etc. |
values / setValue | Read / write field values |
fieldErrors / formErrors | Validation messages |
isFieldVisible(handle) | Conditional visibility |
handleSubmit / goNext / goBack / saveDraft | Submit, multi-page, and save progress |
Save & Continue Later
Enable Save on the form’s Button Layout in Freeform. Vue 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:
<script setup lang="ts">
import { Freeform } from '@solspace/freeform-vue';
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);
}
const { draftToken, draftKey } = readDraft();
const baseUrl = 'https://cms.example.com';
function onSuccess(response: {
status?: string;
draft?: { token?: string; key?: string };
}) {
if (
response.status === 'draft_saved' &&
response.draft?.token &&
response.draft?.key
) {
writeDraft(response.draft.token, response.draft.key);
}
}
</script>
<template>
<Freeform
handle="contact"
:base-url="baseUrl"
:extensions="recommendedExtensions"
:draft-token="draftToken"
:draft-key="draftKey"
:on-success="onSuccess"
/>
</template>
Customer Flow
- Enable Save in Freeform → set Save label (Redirect URL optional).
- Visitor clicks Save → your app stores
token+keyin the URL. - Visitor returns via that URL → pass
draftToken/draftKey→ fields restore. - Visitor clicks Submit → real submission; draft is removed.
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 -->
<Freeform :extensions="recommendedExtensions" … />
| Extension | Covers |
|---|---|
| Captchas | Turnstile, reCAPTCHA, hCaptcha, Friendly Captcha |
datetimeExtension | Flatpickr / native datetime fields |
fileDndExtension | File Drag & Drop uploads |
calculationExtension | Live calculation fields |
tableExtension | Table rows (min/max/exact limits, required columns, file cells) |
signatureExtension | Signature pad (draw, clear, required validation) |
stripePaymentExtension | Stripe Payment Element |
squarePaymentExtension | Square Web Payments SDK |
paypalPaymentExtension | PayPal Buttons |
molliePaymentExtension | Mollie hosted checkout redirect |
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
Payment fields (Stripe, Square, PayPal, Mollie) work the same in Vue as in React: configure the integration in Freeform, add the payment field in the form builder, and pass recommendedExtensions (or the matching *PaymentExtension).
<script setup lang="ts">
import { Freeform } from '@solspace/freeform-vue';
import { recommendedExtensions } from '@solspace/freeform-extensions';
import '@solspace/freeform-theme-default/styles.css';
</script>
<template>
<Freeform
handle="checkout"
base-url=""
:extensions="recommendedExtensions"
/>
</template>
Prefer a same-origin proxy for /freeform/*. Put payment fields on the last page of multi-page forms.
For deep dives (behavior, testing cards, webhooks), view the React JS docs.
Custom Field Renderers
Override rendering by handle, frontend renderer key, or field type:
<script setup lang="ts">
import { Freeform } from '@solspace/freeform-vue';
import MyPaymentField from './MyPaymentField.vue';
import MyTextField from './MyTextField.vue';
const renderers = {
handles: {
payment: MyPaymentField,
},
types: {
text: MyTextField,
},
};
</script>
<template>
<Freeform
handle="donation"
base-url="https://cms.example.com"
:renderers="renderers"
/>
</template>
Same-origin ProxyRecommended
Point your Vite (or other) dev server at Craft so CSRF cookies stay same-origin:
export default {
server: {
proxy: {
'/freeform': {
target: 'https://cms.example.com',
changeOrigin: true,
secure: false, // local TLS only
},
},
},
};
Then use base-url="" (or window.location.origin) in the browser.
Security Notes
- Leave
allowRawHtmlat the default (false) unless HTML fields are trusted. - Enable a captcha on public forms.
- Set
headless.allowedOriginswhen calling Craft cross-origin. - See REST API for CORS and CSRF details.
Nuxt
Freeform’s Vue packages are client-side. In Nuxt, use a .client.vue component (or <ClientOnly>) and proxy /freeform — same pattern as Next.js for React. See the dedicated Nuxt guide.
Example Demos
- Freeform Headless Vue Demo — Vite + Vue 3 app (REST + GraphQL tab)
- Freeform Headless Nuxt Demo — Nuxt 3 +
@solspace/freeform-vue
GraphQL
Prefer the headless GraphQL adapters when you want Craft GraphQL with the same manifest/submit contract as REST. Pass a custom fetch (same pattern as the Vue demo’s graphqlFetch) into <Freeform> / useFreeform().
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.