A schema is a form's best friend.
The type-safe, Zod-first form library for Vue 3 and Nuxt.
Your schema is enough. Hand it to Attaform and get back types, state, validation, errors, SSR, and more, all from one definition. Because Vue devs deserve nice things.
- MIT licensed
- Vue 3 · Nuxt 3 / 4
- Zod 3 / 4
- Tree-shakable ESM
Schema in, form out.
One Zod schema is the source of truth for types, defaults, validation, errors, and metadata. Define it once. Every reactive surface inherits.
One directive. The whole binding stack.
v-register is a Vue directive, not a wrapper component. One line on a native <input> opts that field into typed binding, coercion, and write-time transforms.
From tiny forms to multistep flows.
useForm handles a single-field signup. useWizard composes those forms into a flow with shared state and validation. Same composables, all the way up.
The directive
One line on a native input.
v-register stays on the same <input>. Every option you add opts into another runtime feature without touching the template. The markup never grows.
<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', { transforms: [trim] })" />Same line. The field now runs a sync write-time transform, normalizing the value before it reaches form state.
<input v-register="form.register('email', { transforms: [trim, lowercase], autoAria: false })" />Same line. Compose a transform pipeline and opt this field out of automatic aria wiring, all without touching the markup elsewhere on the page.
Reference
From schema to submit.
One schema, one useForm call, one form handle. Reactive values, live errors, and a submit guard, all from the same source of truth.
<script setup lang="ts">
import { z } from 'zod'
import { useForm } from 'attaform/zod'
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
})
const form = useForm({ schema, key: 'signup' })
const onSubmit = form.handleSubmit((values) => api.signup(values))
</script>
<template>
<form @submit.prevent="onSubmit">
<input v-register="form.register('email')" />
<p v-if="form.fields.email.showErrors">{{ form.fields.email.firstError?.message }}</p>
<button :disabled="form.meta.submitting">Sign up</button>
</form>
</template>Why Attaform
Schema-driven, end to end.
Inferred types. Live validation. Multistep flows. Devtools. Server-side errors, undo/redo. Everything you need, nothing you have to wire.
Schema-driven types
Every path, value, and error is inferred from your Zod schema. No any, no manual type plumbing.
Live validation
Per-field validation on change, blur, or submit. Synchronous by default; async refinements await before submit dispatches.
Accessible by default
aria-invalid, aria-busy, aria-required, and aria-describedby stay in sync with validation state, server-rendered before hydration. Write any aria attribute yourself and Attaform leaves it alone.
Field arrays + undo/redo
Typed append / insert / remove / swap, plus a bounded undo stack you can opt into per-form.
SSR out of the box
Nuxt round-trips the payload automatically; bare Vue uses renderAttaformState / hydrateAttaformState. Server markup matches the hydrated client.
First-class multistep
useWizard composes useForm instances into a flow. Shared navigation, per-step validation, state retained across steps, deep-link restore.
DevTools panel
A Nuxt-auto-wired devtools panel. Walk history, edit values live, inspect every form on the page. No probes to install.
Server-side errors
form.setErrors(response.errors) mounts server-sent ValidationError[] into the same reactive surface your template already reads, each error carrying an optional data payload.
Live editor
A schema is the form.
Edit the schema, edit the template, watch it run. No backend, no build step, every change re-renders live.
Files share one folder. Import siblings as ./Filename.vue.
Multistep
A wizard, batteries included.
useWizard takes an ordered list of step slots and produces a reactive wizard. Form steps gather data; bare string keys mark affordance steps (welcome screens, review surfaces, congrats cards). Universal handleSubmit, shared state, URL sync, all in one composable.
const shipping = useForm({ schema: shippingSchema, key: 'shipping' })
const payment = useForm({ schema: paymentSchema, key: 'payment' })
const wizard = useWizard({
steps: ['welcome', shipping, payment, 'review'],
})Get started in 30 seconds.
One install, one schema, one composable. Read the quick start or jump straight into the demos.