A type-safe, schema-driven form library for Vue 3 and Nuxt with first-class Zod support.
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 β 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')We pair 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.
<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.isSubmitting">Sign up</button>
</form>
</template>useForm({ schema, key }) returns a Pinia-style reactive object β 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 + counters (isSubmitting,isValid,canUndo,submitCount, the flatmeta.errorsaggregate, the per-mountinstanceId, β¦).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, undo / redo, persistence β see the API reference.
- Schema-driven types β every path, value, and error is inferred from the schema; no
any. - 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 β 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 β
register('email', { transforms: [trim, lowercase] })runs sync user-input normalization before storage commit. See recipe. - Discriminated-union variant memory β switching a discriminator (
notify.channel: 'email' β 'sms' β 'email') restores the previous variant's typed subtree by default. SetuseForm({ rememberVariants: false })to drop on switch. - Field arrays β
append/prepend/insert/remove/swap/move/replace, fully typed at the call site. - Drafts + undo / redo β per-field opt-in persistence (
localStorage/sessionStorage/ IndexedDB / custom backend) and a bounded undo stack. - Server errors β
parseApiErrors(payload)normalises a{ message, code }[]wire format; pair withform.setFieldErrors(...). User errors survive schema revalidation. - Stable error codes β every
ValidationErrorcarriescode: string. Library 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(recipe).
- API reference β every public export with signatures and return shapes
- Recipes β task-oriented walkthroughs for every feature above
- Troubleshooting β common gotchas and fixes
- Migration guides β per-release upgrade notes
- Performance β how it scales; when to worry
- Changelog β full release history
Pre-1.0. SemVer applies from v1.0 onward; 0.x minor bumps may still include breaking changes, each documented under docs/migration/.
MIT β see LICENSE.