Skip to main content

Vue.js
LitePro
Improved 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:

ContactForm.vue
<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>
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.
#loading / #error slotsCustom 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
app.css
@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:

HeadlessContactForm.vue
<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:

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. 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:

ContactFormWithSave.vue
<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

  1. Enable Save in Freeform → set Save label (Redirect URL optional).
  2. Visitor clicks Save → your app stores token + key in the URL.
  3. Visitor returns via that URL → pass draftToken / draftKey → fields restore.
  4. 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" />
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
squarePaymentExtensionSquare Web Payments SDK
paypalPaymentExtensionPayPal Buttons
molliePaymentExtensionMollie 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:

vite.config.ts
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 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.

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

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.