A type-safe, schema-driven form library for Vue 3 and Nuxt with first-class Zod support.
Hand a Zod schema to
useFormand Attaform turns it into a reactive form, typed end-to-end, with live errors and SSR out of the box.It scales from the simplest forms to the most comprehensive multistep wizards while keeping the core experience clear and focused.
Because Vue and Nuxt devs deserve nice things, too.
Try it live. Tweak a schema, edit the template, and watch the form rebind in the browser at attaform.com/demos. No install needed.
npm install attaform zodThat's it for client-side rendering. Forms render and validate the moment you call useForm; the registry self-installs on first use.
Nuxt 3 / 4. Add the module:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['attaform/nuxt'],
})Bare Vue + SSR. Add the Vite plugin so server-rendered HTML matches the hydrated client (the plugin injects :value / :checked bindings at compile time):
// vite.config.ts
import vue from '@vitejs/plugin-vue'
import { attaform } from 'attaform/vite'
export default defineConfig({
plugins: [vue(), attaform()],
})The Vite plugin also rewrites attaform/zod imports at build time to attaform/zod-v3 or attaform/zod-v4, so your bundle ships only the adapter you actually use.
App-wide options. Install the Vue plugin if you want to set defaults or disable devtools:
// main.ts
import { createApp } from 'vue'
import { createAttaform } from 'attaform'
createApp(App)
.use(createAttaform({ defaults: { debounceMs: 100 } }))
.mount('#app')Attaform pairs well with noUncheckedIndexedAccess: true:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true
}
}It catches stale form.values.contacts[N] reads at compile time. Nuxt 3 / 4 sets this for you.
Try it live. Skip the install and tweak a schema in your browser at attaform.com/demos.
<script setup lang="ts">
import { z } from 'zod'
import { useForm } from 'attaform/zod' // auto-detects Zod major
const schema = z.object({
username: z.string().min(2, 'At least 2 characters'),
password: z.string().min(8, 'At least 8 characters'),
})
const form = useForm({ schema, key: 'signup' })
const onSubmit = form.handleSubmit(async (values) => {
await $fetch('/api/signup', { method: 'POST', body: JSON.stringify(values) })
})
</script>
<template>
<form @submit.prevent="onSubmit">
<input v-register="form.register('username')" placeholder="Username" />
<small v-if="form.errors.username?.[0]">{{ form.errors.username[0].message }}</small>
<input v-register="form.register('password')" type="password" placeholder="Password" />
<small v-if="form.errors.password?.[0]">{{ form.errors.password[0].message }}</small>
<button :disabled="form.meta.submitting">Sign up</button>
</form>
</template>useForm({ schema, key }) returns a Pinia-style reactive form. Read leaves directly, no .value:
form.values: current values.form.values.username,form.values.address.city.form.errors: per-field errors, keyed by dotted path.form.errors.username?.[0]?.message.form.fields: per-field flags (dirty,touched,errors,blank, …).form.fields.username.dirty.form.meta: form-level flags and counters (submitting,valid,dirty,submissionAttempts, the flatmeta.errorsaggregate, the per-mountinstanceId, …).form.history: consolidated undo/redo namespace (undo(),redo(),clear(),canUndo,canRedo,size).form.register(path): typed two-way binding. Pair withv-registeron<input>/<textarea>/<select>.form.handleSubmit(onValid, onInvalid?): runs validation, dispatches. The valid callback receives the strict Zod-inferred type.form.setValue(path, value),form.reset(), field-array helpers, persistence: see Entry points.
Try it live. Tweak this schema, edit the template, and watch the form rebind in the browser at attaform.com/demos. No install needed.
v-register stays on the same native <input>. Every option you add opts into another runtime feature without touching the template structure.
<input v-register="form.register('email')" />Typed two-way binding to form.values.email, with schema-driven coercion at the directive layer.
<input v-register="form.register('email', { persist: true })" />Same line. The field now writes through to the form's persistence backend on every keystroke, with the sensitive-name guard catching accidental password-style opt-ins.
<input
v-register="form.register('email', { persist: true, transforms: [lowercase], multiTab: false })"
/>Same line. Add a sync DOM-input transform, opt out of multi-tab sync, all without touching the markup elsewhere.
- Schema-driven types. Every path, value, and error is inferred from your Zod schema. No
any, no manual type plumbing. - Live validation.
validateOn: 'change'by default with synchronousdebounceMs: 0.'blur'and'submit'(opt-out) modes available. Async refinements await before submit dispatches. - Schema-driven coercion. String DOM input flows into the schema's typed slot (
string→number,string→boolean) at the directive layer. Default-on; passuseForm({ coerce: false })to disable or a customCoercionRegistryto extend. - Register transforms.
form.register('slug', { transforms: [lowercase, dashify] })runs sync write-time normalization before storage commit. - First-class multistep.
useWizardcomposesuseForminstances into a flow with shared navigation, per-step validation, persistence across steps, and deep-link restore. - DevTools panel. Auto-wired in Nuxt. Walk history, edit values live, inspect every form on the page. No probes to install.
- Discriminated-union variant memory. Switching a discriminator (
notify.channel: 'email' → 'sms' → 'email') restores the previous variant's typed subtree by default. PassuseForm({ rememberVariants: false })to drop on switch. - Field arrays.
append/prepend/insert/remove/swap/move/replace, fully typed at the call site. - Drafts and undo / redo. Per-field opt-in persistence (
localStorage/sessionStorage/ IndexedDB / custom backend) and a bounded undo stack. - Multi-tab sync. Same-keyed
useFormcallsites in same-origin tabs auto-pair overBroadcastChanneland mirror every mutation in near real-time. Sensitive paths filtered both directions; HTTPS-or-localhost required. - Server errors.
parseApiErrors(payload, { formKey: form.key })normalizes any API envelope into the sameValidationErrorshape your template already reads. Pair withform.setFieldErrors(...); user errors survive schema revalidation. - Stable error codes. Every
ValidationErrorcarriescode: string. Attaform codes (atta:) live on the exportedAttaformErrorCodeenum; adapter codes use azod:prefix; consumers pick their own (api:,auth:, …). - Clearable required fields. The
unsetsentinel marks a field displayed-empty while storage holds the schema's slim default. Submit fails with'No value supplied'for required schemas;.optional()/.nullable()/.default(N)opt out. - SSR. Nuxt handles the payload round-trip automatically; bare Vue uses
renderAttaformState/hydrateAttaformState.
Full docs live at www.attaform.com:
- Quick start: schema, form, submit, in one page.
- Why Attaform: the convictions behind the design.
- Entry points: every public export, grouped by subpath.
- useWizard: multistep flows with shared state and validation.
- Performance: how it scales; when to worry.
- Troubleshooting: common gotchas and fixes.
- Changelog: full release history.
Pre-1.0. SemVer applies from v1.0 onward; 0.x minor bumps may still include breaking changes, each documented in the Changelog.
MIT; see LICENSE.