# NotForm
> Vue forms without the friction.
Canonical Origin: https://notformdocs.vercel.app/
## LLM Resources
- [Full Content](https://notformdocs.vercel.app/llms-full.txt): Complete page content in markdown format.
---
- **Page:** NotForm - Vue Forms without the friction
- **Source:** https://notformdocs.vercel.app/
- **Description:** Vue forms without the friction.
```yaml
meta:
og:image: https://notformdocs.vercel.app/_og/s/o_65377eab73c456c4.png
og:image:height: "600"
og:image:type: image/png
og:image:width: "1200"
twitter:image: https://notformdocs.vercel.app/_og/s/o_65377eab73c456c4.png
```
# Vue Forms without the friction.
Headless form state and validation library for Vue 3. Bring your own schema, keep your own components, and stay in control of every interaction.
[**Read the Docs **](https://notformdocs.vercel.app/docs/getting-started/introduction) [** Try the Playground **](https://notformdocs.vercel.app/playground)
- **Bring your own schema**
Use Zod, Valibot, ArkType, or any Standard Schema validator. Your validation library stays completely separate from your form state.
- **Headless by design**
Headless components and composables give you state, events, and validation without forcing a design system, markup structure, or styling approach.
- **Isolated form instances**
Keep multiple independent forms on the same page. Every form owns its own values, errors, lifecycle, and submission state.
- **Granular validation**
Validate everything or target a specific field, object, or array scope. Choose when validation runs with blur, change, input, or manually.
- **First-class arrays**
Append, prepend, insert, update, remove, swap, and move array items while keeping their associated state in sync.
- **Stable dynamic fields**
Stable array item keys keep reordered fields tied to the right item, avoiding the remount and state-shuffling problems of index-based keys.
- **Type-safe by default**
Field paths, values, and schema input types are inferred from your schema, so invalid paths and incompatible values are caught before runtime.
- **Async-safe validation**
Async validation stays predictable even when values change while validation is in flight. Stale results cannot overwrite newer form state.
- **Server error handling**
Set server-side issues directly onto fields, render them through the same error system, and keep server errors alongside schema validation.
```json
{"@context":"https://schema.org","@graph":[{"@id":"https://notformdocs.vercel.app/#website","@type":"WebSite","description":"Vue forms without the friction.","name":"NotForm","url":"https://notformdocs.vercel.app/","publisher":{"@id":"https://notformdocs.vercel.app/#identity"}},{"@id":"https://notformdocs.vercel.app/#webpage","@type":"WebPage","description":"Vue forms without the friction.","url":"https://notformdocs.vercel.app/","about":{"@id":"https://notformdocs.vercel.app/#identity"},"isPartOf":{"@id":"https://notformdocs.vercel.app/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://notformdocs.vercel.app/"]}]},{"@id":"https://notformdocs.vercel.app/#identity","applicationCategory":"DeveloperApplication","description":"Vue forms without the friction.","name":"NotForm","operatingSystem":"Web","url":"https://notformdocs.vercel.app","@type":["Organization","SoftwareApplication"],"aggregateRating":{"@type":"AggregateRating","bestRating":"5","ratingValue":"4.9","reviewCount":"13","worstRating":"1"},"author":{"@type":"Person","name":"Favour Emeka","url":"https://favorodera.vercel.app"},"keywords":["Vue forms","Nuxt form validation","Vue 3 form library","TypeScript form validation","headless form components"],"offers":{"@type":"Offer","price":"0","priceCurrency":"USD"},"sameAs":["https://www.npmjs.com/package/notform","https://www.npmjs.com/package/@notform/nuxt","https://github.com/favorodera/notform"]}]}
```
---
- **Page:** Playground | NotForm
- **Source:** https://notformdocs.vercel.app/playground
- **Description:** Interactive Vue 3 playground for NotForm.
```yaml
meta:
og:image: https://notformdocs.vercel.app/_og/s/o_e48b6827c1d53a45.png
og:image:height: "600"
og:image:type: image/png
og:image:width: "1200"
twitter:image: https://notformdocs.vercel.app/_og/s/o_e48b6827c1d53a45.png
```
# NotForm Playground
Loading playground
```json
{"@context":"https://schema.org","@graph":[{"@id":"https://notformdocs.vercel.app/#website","@type":"WebSite","description":"Vue forms without the friction.","name":"NotForm","url":"https://notformdocs.vercel.app/","publisher":{"@id":"https://notformdocs.vercel.app/#identity"}},{"@id":"https://notformdocs.vercel.app/playground#webpage","@type":"WebPage","description":"Interactive Vue 3 playground for NotForm.","name":"Playground","url":"https://notformdocs.vercel.app/playground","isPartOf":{"@id":"https://notformdocs.vercel.app/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://notformdocs.vercel.app/playground"]}]},{"@id":"https://notformdocs.vercel.app/#identity","applicationCategory":"DeveloperApplication","description":"Vue forms without the friction.","name":"NotForm","operatingSystem":"Web","url":"https://notformdocs.vercel.app","@type":["Organization","SoftwareApplication"],"aggregateRating":{"@type":"AggregateRating","bestRating":"5","ratingValue":"4.9","reviewCount":"13","worstRating":"1"},"author":{"@type":"Person","name":"Favour Emeka","url":"https://favorodera.vercel.app"},"keywords":["Vue forms","Nuxt form validation","Vue 3 form library","TypeScript form validation","headless form components"],"offers":{"@type":"Offer","price":"0","priceCurrency":"USD"},"sameAs":["https://www.npmjs.com/package/notform","https://www.npmjs.com/package/@notform/nuxt","https://github.com/favorodera/notform"]}]}
```
---
- **Page:** Introduction
- **Source:** https://notformdocs.vercel.app/docs/getting-started/introduction
- **Description:** NotForm is a headless, schema-driven form library for Vue 3 and Nuxt. See what it does, what it leaves to you, and where to start.
NotForm manages form state for Vue 3 and Nuxt. You describe your data with a schema, NotForm turns it into a typed reactive form, and you keep full control of the markup and styling.
```vue
```
## What you get
- **Types from your schema.** Field paths, field values, `form.values`, and the data passed to `onSubmit` are all inferred. A mistyped path is a compile error.
- **Validation you can tune.** Choose, per field, whether blur, change, or input triggers validation, whether errors clear eagerly, and how long to debounce.
- **Stable fields.** `form.field(path)` always returns the same instance for the same path, so touched state and pending validation survive re-renders.
- **Repeatable sections.** Array fields give every item a stable identity and keep errors attached to the right item when you add, remove, move, or swap.
- **Plain, reactive state.** `isDirty`, `isSubmitting`, `isValidating`, and the rest are ordinary reactive properties. There is nothing to subscribe to.
- **Any validator.** Zod, Valibot, ArkType, or any other library that implements [Standard Schema](https://standardschema.dev).
## What NotForm leaves to you
- **UI.** NotForm renders a native `
` and nothing else. Inputs, labels, layout, and styling are yours, or your component library's.
- **Validation rules.** Rules are written in your schema, not in NotForm.
- **Networking.** `onSubmit` receives validated data. How you send it is up to you.
- **Global state.** Every `useForm` call creates an independent form. There is no store to configure.
## Packages
Package
Use it for
notform
Vue 3 and Vite applications. Import components and
useForm
explicitly.
@notform/nuxt
Nuxt 4 applications. Registers the same components and
useForm
as auto-imports.
These docs describe NotForm v4. If your project uses NotForm 3.x, start with the [v3 migration guide](https://notformdocs.vercel.app/docs/migrations/notform-v3).
## How this docs is organized
Install NotForm, build your first form, and learn the mental model.
Task-oriented explanations of binding inputs, validation, errors, submission, state, and arrays.
Complete solutions to common problems such as server errors, async checks, and multi-step forms.
Exact contracts for components, `useForm`, and every public type.
## Suggested reading order
1. [Installation](https://notformdocs.vercel.app/docs/getting-started/installation)
2. [Quickstart](https://notformdocs.vercel.app/docs/getting-started/quickstart)
3. [Core concepts](https://notformdocs.vercel.app/docs/getting-started/core-concepts)
4. [Binding inputs](https://notformdocs.vercel.app/docs/guides/binding-inputs) and [Validation](https://notformdocs.vercel.app/docs/guides/validation)
5. Whatever your form needs next: [errors](https://notformdocs.vercel.app/docs/guides/errors), [array fields](https://notformdocs.vercel.app/docs/guides/array-fields), or a [recipe](https://notformdocs.vercel.app/docs/recipes/server-errors)
Building with an AI coding assistant? See [Working with AI](https://notformdocs.vercel.app/docs/ai/working-with-ai).
---
- **Page:** Binding inputs
- **Source:** https://notformdocs.vercel.app/docs/guides/binding-inputs
- **Description:** Connect text inputs, textareas, numbers, checkboxes, radios, selects, dates, and files to NotForm fields.
Every control connects to a field the same way: write the value through `field.model`, and report interaction through `field.events`. This page shows the pattern for each native input type. For custom components, see [Custom components](https://notformdocs.vercel.app/docs/integrations/custom-components).
## The pattern
```vue
Email
```
- `field.model` is a two-way binding to `form.values` at the field's path. Writing to it does **not** validate.
- `field.events` is `{ onBlur, onChange, onInput }`. These mark the field touched and trigger validation according to the field's configuration.
- Put `v-model` before `v-bind="field.events"`. The value must be written before the validation event reads it.
## Always give initial values
Though not mandatory, provide an entry in `initialValues` for every field you bind. Missing values reach your schema as `undefined`, so validators report their generic "expected string, received undefined" message instead of your custom one, and `isDirty` can become `true` after a user types and deletes.
```ts
const form = useForm({
initialValues: {
age: undefined,
interests: [],
name: '',
newsletter: false
},
schema,
})
```
## Text, email, password, search, URL
```vue
Name
```
Native modifiers work as usual. `v-model.trim` for example removes surrounding whitespace as the user types.
## Textarea
```vue
Message
```
## Number
```vue
Quantity
```
```ts
const schema = z.object({ quantity: z.number().min(1, 'Order at least one.') })
// initialValues: { quantity: 1 }
```
`v-model.number` converts typed text to a number. If the user clears the box, the browser reports an empty string, which a `z.number()` schema rejects with its type message. Set a custom type message in your schema, or keep the input as a string and convert in the schema. See [Schema input and output](https://notformdocs.vercel.app/docs/guides/schema-input-output).
## Single checkbox
Bind a boolean.
```vue
I accept the terms
```
```ts
const schema = z.object({
acceptTerms: z.boolean('You must accept the terms.'),
})
// initialValues: { acceptTerms: false }
```
## Checkbox group
Bind one field to an array of the selected values. Each checkbox has its own `:value`.
```vue
Interests
{{ option }}
```
```ts
const schema = z.object({
interests: z.array(z.string()).min(1, 'Pick at least one.'),
})
// initialValues: { interests: [] }
```
A checkbox group is a plain `form.field()` whose value happens to be an array of fixed choices. Use [`form.arrayField()`](https://notformdocs.vercel.app/docs/guides/array-fields) when users add, remove, or reorder rows of structured data.
## Radio group
All radios share one field and differ by `:value`.
```vue
Plan
{{ option }}
```
## Select
```vue
Nationality
Choose a country
{{ country.name }}
```
For ``, bind an array exactly like a checkbox group.
## Dates and times
Native date and time inputs produce **strings** such as `2026-11-02`. Bind them like text, and validate or transform them in the schema.
```vue
Birthday
```
## Files
`v-model` does not work on file inputs. Write the file with an event handler instead and report the change yourself.
```vue
Avatar
{
field.model = (event.target as HTMLInputElement).files?.[0]
field.events.onChange()
}"
@blur="field.events.onBlur"
>
```
## Skipping the Field component
`Field` only hands the field to its slot. If you prefer, create the field in script and use it directly. This is common in reusable input components.
```vue
```
## Labels and accessibility
- Use `field.path` for the control's `id` and the label's `for`. `field.id` is a symbol and is not valid as an HTML id.
- Mark invalid controls with `:aria-invalid="!field.isValid"`.
- Link errors to controls with `aria-describedby`. See [Errors](https://notformdocs.vercel.app/docs/guides/errors#accessible-error-markup).
## Next steps
- [Validation](https://notformdocs.vercel.app/docs/guides/validation) controls when errors appear.
- [Errors](https://notformdocs.vercel.app/docs/guides/errors) covers rendering every kind of error.
---
- **Page:** Core concepts
- **Source:** https://notformdocs.vercel.app/docs/getting-started/core-concepts
- **Description:** The mental model behind NotForm: the schema, the form instance, fields, paths, the initial-values baseline, and what happens on every keystroke and submit.
Everything in NotForm follows from a handful of ideas. Read this page once and the rest of the documentation becomes predictable.
## The schema is the source of truth
You pass an **object** schema to `useForm`. NotForm derives three things from it:
- the type of `form.values`, which is the schema's **input**;
- the paths that `form.field()` and `form.arrayField()` accept;
- the type of the data that `onSubmit` receives, which is the schema's **output**.
The schema also performs all validation. NotForm has no rule syntax of its own.
Input and output can differ. A schema that turns the text `'42'` into the number `42` has input `{ age: string }` and output `{ age: number }`. Inputs bind to the values users edit. Outputs are what your code receives. See [Schema input and output](https://notformdocs.vercel.app/docs/guides/schema-input-output).
## One form instance owns the state
`useForm` returns a reactive form instance. It owns:
- `values`: the live, editable data;
- `errors`: the current list of validation issues;
- state flags such as `isDirty`, `isSubmitting`, and `isValidating`;
- actions such as `submit`, `validate`, `reset`, `setError`, and `setValue`.
You read state directly (`form.isDirty`, not `form.isDirty.value`) and pass the instance to ``.
## Fields are handles, not copies
`form.field('email')` does not store a value. It returns a handle whose `model` reads and writes `form.values.email`, and whose `errors`, `isTouched`, `isDirty`, and events are scoped to that path.
Fields are **cached by path**. Calling `form.field('email')` again, including on every render, returns the same instance with the same `id`. That is why touched state and pending validation survive re-renders.
Consequence: the optional configuration passed to `form.field(path, config)` is read **once**, on the first call for that path.
`Field` is a convenience component that hands a field to its slot. It is optional. A field is also usable straight from script.
## Paths are strings; issue paths are segments
You address values with dot-notation **paths**: `'email'`, `'address.city'`, `'contacts.0.email'`. Paths are checked against the schema input.
Validators report where an issue occurred as an array of **segments**: `['address', 'city']`. When you set errors yourself, you write segments:
```ts
form.setError({ message: 'Already taken.', path: ['username'] })
```
An issue with no `path` applies to the whole form.
## Initial values are a baseline
`initialValues` and `initialErrors` define the baseline. The form compares against it to compute `isDirty`, and `reset()` restores it. `reset(values, errors)` replaces the baseline, which is how you load saved data into an edit form.
## What happens when a user types
1. The input updates `field.model`, which writes into `form.values`.
2. The input's event (`input`, `change`, or `blur`) calls the matching handler in `field.events`.
3. The handler checks the field's configuration: is this event enabled, or is the field in eager mode with an existing error?
4. If a debounce is configured, validation waits for a pause. Blur never waits.
5. NotForm validates a snapshot of the whole form with your schema.
6. Only the issues at this field's exact path replace the field's errors.
7. If the user changed any value while validation was running, the result is discarded as **stale** and the errors are left alone.
Details are in [Validation](https://notformdocs.vercel.app/docs/guides/validation).
## What happens when a user submits
1. If a submit is already running, the new one is ignored.
2. `isSubmitting` becomes `true` and `submitCount` increases. Every field now counts as touched.
3. The whole form is validated.
4. If a value changed while validating, validation runs again.
5. If the schema reports issues, they replace all errors and `onSubmit` is **not** called.
6. If it passes, errors are cleared and `onSubmit` is awaited with the validated output.
7. `isSubmitting` returns to `false`, even if `onSubmit` throws.
Details are in [Submission and reset](https://notformdocs.vercel.app/docs/guides/submission-and-reset).
## Glossary
Term
Meaning
Schema input
The shape of
form.values
: what users edit, before parsing.
Schema output
The shape
onSubmit
receives: the parsed result.
Path
A dot-notation string such as
'address.city'
. Used with
form.field()
and
form.setValue()
.
Segments
A path as an array, such as
['address', 'city']
. Used by issues.
Issue
One validation problem: a
message
and an optional
path
.
Field instance
The cached handle returned by
form.field(path)
.
Array field
A field for an array, with
items
and mutation methods. Returned by
form.arrayField(path)
.
Item
One entry of an array field. It is a field for its own value and has an
index
.
Baseline
The current
initialValues
and
initialErrors
.
Touched
A field was blurred, or a submit was attempted.
Dirty
Current values differ from the baseline.
Stale
A validation result that was made obsolete by a newer run or a value change. It is discarded.
## Next steps
- [Binding inputs](https://notformdocs.vercel.app/docs/guides/binding-inputs) shows how to connect every kind of control.
- [Validation](https://notformdocs.vercel.app/docs/guides/validation) explains how to tune the behavior described above.
---
- **Page:** Schema input and output
- **Source:** https://notformdocs.vercel.app/docs/guides/schema-input-output
- **Description:** Understand how NotForm types the values users edit separately from the validated data your submit handler receives.
A schema can accept one shape and produce another. NotForm keeps these two shapes distinct everywhere:
Input
Output
What it is
What users edit, before parsing
What the schema produces after validating
Where it appears
form.values
,
initialValues
,
field.model
,
form.setValue()
The argument of
onSubmit
, and
{ value }
from
form.validate()
Type helper
SchemaInput
SchemaOutput
## A transform changes the output
```ts
import { z } from 'zod'
const orderSchema = z.object({
quantity: z.string().transform(Number),
})
const form = useForm({
initialValues: { quantity: '2' },
onSubmit(data) {
data.quantity.toFixed(0) // data.quantity is a number
},
schema: orderSchema,
})
form.values.quantity // a string
```
The input control holds text, so the form value stays a string. Your handler receives the converted number. The same distinction applies to every Standard Schema validator.
## Common ways input and output differ
Schema feature
Effect on the shapes
Transform or coercion
The output type differs from the input type.
Default value
The key is optional in the input and required in the output.
Trimming or normalizing
form.values
keeps what the user typed. The output is normalized.
Pipes into another schema
The output is the final stage. The input is the first.
Because `form.values` always holds the input, the text a user typed is never silently rewritten. Normalization happens only in the data you receive on submit.
## Initial values
`initialValues` use the **input** shape, and may be partial. They are the values controls display, so use the representation your controls and schema expect, not the parsed output.
```ts
useForm({
initialValues: { quantity: '2' }, // input shape, not a number
schema,
})
```
Though not mandatory, provide a value for every field you bind. A key missing from `initialValues` reaches the schema as `undefined`, and validators then report their generic type message instead of your custom one. Empty strings, `false`, and `[]` are the usual starting values for text, checkboxes, and lists.
## Typed paths and values
Paths and values for `form.field()`, `form.arrayField()`, and `form.setValue()` come from the input shape. A path that does not exist is a TypeScript error, and `setValue` checks the value type.
```ts
form.setValue('quantity', '3') // ok
form.setValue('quantity', 3) // type error: input is a string
form.field('quantty') // type error: no such path
```
## Naming the types
Import the helpers when you need to name a shape, for example in a function signature.
```ts
import type { SchemaInput, SchemaOutput } from 'notform'
type OrderInput = SchemaInput
type OrderOutput = SchemaOutput
async function saveOrder(order: OrderOutput) {
// ...
}
```
See [TypeScript](https://notformdocs.vercel.app/docs/guides/typescript) for more patterns.
## Next steps
- [Validation](https://notformdocs.vercel.app/docs/guides/validation) explains when the schema runs.
- [Submission and reset](https://notformdocs.vercel.app/docs/guides/submission-and-reset) explains how output reaches `onSubmit`.
---
- **Page:** Quickstart
- **Source:** https://notformdocs.vercel.app/docs/getting-started/quickstart
- **Description:** Build a complete sign-in form with schema validation, field errors, a submit handler, and reset.
This page builds a sign-in form from start to finish. You will define a schema, create a form, bind two inputs, and handle submission.
It assumes you have completed [Installation](https://notformdocs.vercel.app/docs/getting-started/installation).
## 1. Define a schema
The schema describes the values the user edits and the rules they must satisfy.
```vue
```
## 2. Create the form
`useForm` takes the schema, the starting values, and a submit handler.
```vue
```
- `initialValues` are what the inputs show first. See [Schema input and output](https://notformdocs.vercel.app/docs/guides/schema-input-output#initial-values).
- `onSubmit` runs only after the whole schema validates. `data` is the validated output.
## 3. Bind the fields
`form.field(path)` creates a field. `Field` hands it to your template through a slot.
```vue
Email
```
- `v-model="field.model"` reads and writes the value in `form.values`.
- `v-bind="field.events"` spreads `onBlur`, `onChange`, and `onInput`, which tell NotForm when to validate.
- `FieldError` shows the first error for this field and renders nothing when there is none.
## 4. Render and submit
`Form` renders a native ``. Native submit calls `form.submit`, and native reset calls `form.reset`.
```vue
Email
Password
{{ form.isSubmitting ? 'Signing in...' : 'Sign in' }}
Reset
```
`novalidate` turns off the browser's built-in constraint validation so your schema controls every message.
## What you just built
Behavior
Why it happens
Leaving an empty email field shows an error.
Fields validate on
blur
and
change
by default.
Typing does not show errors on a pristine field.
Input
events only validate after the field already has an error.
After an error appears, fixing the value clears it as you type.
Default
eager
mode re-validates on input once a field has an error.
Clicking Sign in with invalid values shows every error and does not call
onSubmit
.
Submit validates the whole form first.
The button is disabled while the request runs.
form.isSubmitting
stays
true
until
onSubmit
settles.
Reset restores the starting values and clears errors.
Form
calls
form.reset()
on native reset.
## Next steps
- Learn the model behind this in [Core concepts](https://notformdocs.vercel.app/docs/getting-started/core-concepts).
- Bind checkboxes, radios, selects, and more with [Binding inputs](https://notformdocs.vercel.app/docs/guides/binding-inputs).
- Tune when errors appear in [Validation](https://notformdocs.vercel.app/docs/guides/validation).
- Show errors returned by your API with [Server errors](https://notformdocs.vercel.app/docs/recipes/server-errors).
---
- **Page:** Nuxt module
- **Source:** https://notformdocs.vercel.app/docs/getting-started/nuxt-module
- **Description:** Register @notform/nuxt to use NotForm's components and useForm as Nuxt auto-imports, and resolve naming conflicts with aliases.
`@notform/nuxt` registers NotForm's public components and composable as Nuxt auto-imports. It does not change validation, field, or submission behavior. Every other page in these docs applies unchanged.
The module requires Nuxt 4 or later. For Vue or Vite applications, use the [`notform` package](https://notformdocs.vercel.app/docs/getting-started/installation).
## Install and register
```bash [pnpm]
pnpm add @notform/nuxt zod
```
```bash [npm]
npm install @notform/nuxt zod
```
```bash [yarn]
yarn add @notform/nuxt zod
```
```bash [bun]
bun add @notform/nuxt zod
```
```ts [nuxt.config.ts]
export default defineNuxtConfig({
modules: ['@notform/nuxt'],
})
```
Restart the dev server after changing the module list.
## What is auto-imported
Name
Kind
useForm
Composable
Form
Component
Field
Component
ArrayField
Component
FieldError
Component
Your schema library is not auto-imported. Import it as usual.
```vue
Email
Submit
```
## Import types
Types are not auto-imported. To import them, add `notform` as a direct dependency of your app. Package managers such as pnpm do not expose a module's own dependencies to your code.
```bash
pnpm add notform
```
```ts
import type { SchemaInput, SchemaOutput, UseFormInstance } from 'notform'
```
The same applies if you prefer explicit imports of components or `useForm` over auto-imports. Both approaches work together.
## Avoid name conflicts
If your app, or another module, already defines `Form`, `Field`, `FieldError`, `ArrayField`, or `useForm`, register NotForm's version under another name with the `aliases` option. Add only the aliases you need.
```ts [nuxt.config.ts]
export default defineNuxtConfig({
modules: ['@notform/nuxt'],
notform: {
aliases: {
Field: 'NotField',
FieldError: 'NotFieldError',
Form: 'AppForm',
useForm: 'useAppForm',
},
},
})
```
Then use the alias everywhere:
```vue
```
Keys are the original NotForm names. Values are the names you want to use.
Component libraries that ship their own `Field` or `FieldError`, such as ShadcnVue, are the most common source of conflicts. See [ShadcnVue](https://notformdocs.vercel.app/docs/integrations/shadcn-vue).
## Server-side rendering
NotForm creates form state during setup and does not need browser APIs to do it, so forms render on the server. Validation timers and event handlers only run in the browser, in response to user interaction.
## Next steps
- Build your first form with the [Quickstart](https://notformdocs.vercel.app/docs/getting-started/quickstart).
- Use NotForm with Nuxt UI in the [Nuxt UI guide](https://notformdocs.vercel.app/docs/integrations/nuxt-ui).
---
- **Page:** Installation
- **Source:** https://notformdocs.vercel.app/docs/getting-started/installation
- **Description:** Install NotForm and a schema validator in a Vue 3, Vite, or Nuxt application.
NotForm ships as two packages that expose the same API.
Package
Use it for
notform
Vue 3 and Vite applications.
@notform/nuxt
Nuxt 4 applications. Adds auto-imports for the same API.
## Requirements
- **Vue 3.5 or later** (declared as a peer dependency of `notform`).
- **Nuxt 4 or later** when using `@notform/nuxt`.
- **A Standard Schema-compatible validator**, installed separately. The examples use Zod.
- **Node.js 24 or later**. Depending on your package manager or environment settings, an older version may warn or fail.
## Vue and Vite
Install `notform` and a validator:
```bash [pnpm]
pnpm add notform zod
```
```bash [npm]
npm install notform zod
```
```bash [yarn]
yarn add notform zod
```
```bash [bun]
bun add notform zod
```
NotForm needs no Vue plugin and no global registration. Import what you use:
```ts
import { ArrayField, Field, FieldError, Form, useForm } from 'notform'
```
## Nuxt
Install the module and a validator:
```bash [pnpm]
pnpm add @notform/nuxt zod
```
```bash [npm]
npm install @notform/nuxt zod
```
```bash [yarn]
yarn add @notform/nuxt zod
```
```bash [bun]
bun add @notform/nuxt zod
```
Register the module, then restart the dev server:
```ts [nuxt.config.ts]
export default defineNuxtConfig({
modules: ['@notform/nuxt'],
})
```
`useForm`, `Form`, `Field`, `ArrayField`, and `FieldError` are now auto-imported. See the [Nuxt module guide](https://notformdocs.vercel.app/docs/getting-started/nuxt-module) for aliases and type imports.
Code samples in these docs import from `notform` so they work anywhere. In a Nuxt project, delete the `import ... from 'notform'` line and keep the rest.
## Choose a validator
NotForm accepts any object schema that implements [Standard Schema](https://standardschema.dev). It does not bundle a validator and does not translate rules between libraries. Switching validators changes only the schema.
```ts [Zod]
import { z } from 'zod'
const schema = z.object({
email: z.email('Enter a valid email address.'),
})
```
```ts [Valibot]
import * as v from 'valibot'
const schema = v.object({
email: v.pipe(v.string(), v.email('Enter a valid email address.')),
})
```
```ts [ArkType]
import { type } from 'arktype'
const schema = type({
email: 'string.email',
})
```
Zod examples use the Zod 4 API (`z.email()`). On Zod 3, write `z.string().email()` instead. Standard Schema support requires Zod 3.24 or later, and recent releases of Valibot and ArkType.
The schema must be an **object** schema. A bare `z.string()` is rejected by TypeScript, because form values are always a keyed object.
## Verify the install
Render this component. Submitting an empty field should show the message, and a valid email should log the data.
```vue
Email
Submit
```
## Next steps
Continue with the [Quickstart](https://notformdocs.vercel.app/docs/getting-started/quickstart).
---
- **Page:** Errors
- **Source:** https://notformdocs.vercel.app/docs/guides/errors
- **Description:** Render field, form-level, and section errors, add server errors, and make error messages accessible.
All errors are **issues**: an object with a `message` and an optional `path`.
```ts-type
{ message: 'Enter a valid email address.', path: ['email'] }
```
- `path` is an array of segments. Segments are property names and array indices: `['contacts', 0, 'email']`. Depending on the validator, a segment may be a string, a number, or an object with a `key`.
- An issue **without** a path is a form-level issue.
`form.errors` is the full list. Each field exposes the issues at its own exact path as `field.errors`.
## Show a field's error
`FieldError` renders the first message and nothing when there is no error.
```vue
Email
```
Style it, change its element, or use a slot. See [FieldError](https://notformdocs.vercel.app/docs/components/field-error).
```vue
```
## Show every error
`FieldError` shows only the first message. To list all of them, render `field.errors`.
```vue
```
## Show form-level errors
Read `form.errors` and keep the issues without a path.
```vue
{{ issue.message }}
```
You can also pass an issue array straight to `FieldError`: ` `.
## Show errors for a section
A field shows only issues at its exact path. An issue on `['address']` does not appear on the `address.city` field. To summarize a whole section, filter by the first segment.
```ts
function keyOf(segment: PropertyKey | { key: PropertyKey }) {
return typeof segment === 'object' ? segment.key : segment
}
function sectionErrors(section: string) {
return form.errors.filter((issue) => {
return issue.path?.[0] !== undefined && String(keyOf(issue.path[0])) === section
})
}
```
## Show an error summary
After a failed submit, `form.errors` holds every problem in the form. It is the natural source for a summary at the top of a long form.
```vue
Fix {{ form.errors.length }} problem(s) to continue.
```
## Add errors yourself
`form.setError(issue)` adds an error, typically after a server response.
```ts
form.setError({ message: 'This email address is already in use.', path: ['email'] })
form.setError({ message: 'We could not reach the server.' }) // form-level
```
- With a `path`, it **replaces** the errors already at that exact path.
- Without a `path`, it **appends** a form-level error. Calling it twice adds two.
See [Server errors](https://notformdocs.vercel.app/docs/recipes/server-errors) for a complete submit flow.
## Start with errors
Pass `initialErrors` when a form should open with issues, for example after a server-side render.
```ts
const form = useForm({
initialErrors: [{ message: 'Verify this address.', path: ['email'] }],
initialValues: { email: 'jane@example.com' },
schema,
})
```
`reset()` returns to the current initial errors. `reset(values, errors)` replaces them.
## When errors change
Event
Effect on errors
A field validates (blur, change, input,
field.validate()
)
Replaces only the errors at that field's exact path.
form.validate()
Replaces all errors with the schema's result.
Submit
Replaces all errors with the schema's result. A passing submit clears them before
onSubmit
runs.
form.setError(issue)
Adds or replaces errors at one path, or appends a form-level error.
An array mutation
Errors stay attached to the item they belong to. Removed items lose their errors. See
Array fields
.
form.reset()
Restores the initial errors.
Because a field validation replaces the errors at its path, a server error on `email` disappears the next time `email` validates and the schema reports no problem there. Form-level server errors stay until the next submit or reset. This is usually what you want, since editing a field makes its server error stale.
## Accessible error markup
Connect each message to its control so assistive technology announces it.
```vue
Email
```
`FieldError` forwards `id`, `class`, `role`, and other attributes to the element it renders.
## Next steps
- [Submission and reset](https://notformdocs.vercel.app/docs/guides/submission-and-reset)
- [Server errors recipe](https://notformdocs.vercel.app/docs/recipes/server-errors)
---
- **Page:** Validation
- **Source:** https://notformdocs.vercel.app/docs/guides/validation
- **Description:** How NotForm validates, when each field validates, and how to tune triggers, debounce, async rules, and stale results.
Rules live in your schema. NotForm decides **when** to run the schema and **which errors** to keep.
## How validation works
NotForm always validates the whole form against a snapshot of the current values. What differs is which results are kept:
- A **field validation** is triggered by a field event or `field.validate()`. The whole schema runs, but only issues at that field's **exact path** replace that field's errors. Other errors are untouched.
- A **form validation** is triggered by `form.validate()` or a submit. Every error is replaced with the schema's result.
Three consequences are worth remembering:
1. An issue reported on a parent path such as `['address']` is not shown by the `address.city` field, and the reverse is also true.
2. A rule that spans fields must report its issue on the path of the field that should display it. See [Cross-field validation](https://notformdocs.vercel.app/docs/recipes/cross-field-validation).
3. Every field validation executes the entire schema. This matters for slow or asynchronous rules. See [Async rules and performance](#async-rules-and-performance).
## When fields validate
Each field has three trigger events. These are the defaults:
Event
Default
Behavior
blur
Validates immediately
Always marks the field touched, even if validation on blur is turned off.
change
Validates
Fires when a control commits a value, such as picking an option.
input
Does not validate
Fires while typing. Validates only if the field already has an error and the mode is
eager
.
Together with the default `eager` mode, this gives the most common behavior: no errors while a user first types, an error when they leave the field, and the error clearing as soon as they fix it.
## Configure a field
Pass a `FieldConfig` as the second argument to `form.field()` or `form.arrayField()`.
```ts
const username = form.field('username', {
debounce: 300,
validateOn: { input: true },
validationMode: 'lazy',
})
```
Option
Type
Default
Meaning
validateOn
{ blur?: boolean, change?: boolean, input?: boolean }
blur
and
change
on,
input
off
Events that trigger validation. Omitted keys keep their defaults.
validationMode
'eager' | 'lazy'
'eager'
eager
: once a field has errors,
input
and
change
re-validate it.
lazy
: only events enabled in
validateOn
validate.
debounce
number
(ms)
none
Delay for
input
and
change
validation. Blur and submit are never delayed.
The config is read once, when the field is first created. Later calls with the same path return the cached field and ignore any new config. Create each field with its config in one place, preferably in `
Submit
Reset
```
## Props
The form instance returned by `useForm`.
## Slots
Slot
Props
Description
default
none
The form's contents. Read state from the
form
instance you created.
## Behavior
Native event
What
Form
does
submit
Calls
form.submit(event)
, which prevents the browser's page navigation, validates the whole form, and calls
onSubmit
only when valid.
reset
Prevents the browser's native value reset and calls
form.reset()
, restoring the baseline values and errors.
## Attributes
Attributes such as `id`, `class`, `novalidate`, and `aria-*` are forwarded to the native `` element. Add `novalidate` when you want your schema, not the browser, to produce validation messages.
## Form does not provide context
`Form` does not share the form instance with descendants, and fields do not look it up. Create each field from the instance and pass it explicitly:
```vue
...
...
```
This is what keeps every field fully typed. It also means a field can be created and used anywhere, including outside `Form`.
## Using a plain form element
`Form` is optional. A native ` ` with two handlers is equivalent:
```vue
```
## Related
- [Submission and reset](https://notformdocs.vercel.app/docs/guides/submission-and-reset)
- [useForm](https://notformdocs.vercel.app/docs/composables/use-form)
---
- **Page:** TypeScript
- **Source:** https://notformdocs.vercel.app/docs/guides/typescript
- **Description:** Use inferred paths and values, name schema types, type wrapper components, and pass forms between components.
NotForm is typed from your schema. In most forms you never write a NotForm type by hand.
## What is inferred
```ts
const schema = z.object({
address: z.object({
city: z.string()
}),
email: z.string(),
tags: z.array(z.string()),
})
const form = useForm({
initialValues: {
address: { city: '' },
email: '',
tags: []
},
schema
})
form.field('email') // FieldInstance
form.field('address.city') // FieldInstance
form.field('tags.0') // FieldInstance
form.arrayField('tags') // ArrayFieldInstance
form.field('adress') // type error: path does not exist
form.arrayField('email') // type error: 'email' is not an array
```
Inside a slot, `field` is typed from the path, so `field.model` has the right type.
## Name the schema types
```ts
import type { SchemaInput, SchemaOutput } from 'notform'
type FormValues = SchemaInput
type SubmitData = SchemaOutput
async function save(data: SubmitData) {
// ...
}
```
Use these when a function takes `onSubmit` data, or when you build initial values elsewhere:
```ts
const initialValues: FormValues = {
address: { city: '' },
email: '',
tags: []
}
```
`initialValues` accepts a deep-partial of the input, so you can start with only some keys. Field paths still come from the full input shape.
## Path helpers
Type
Meaning
InputPath
Every valid path into the input. Accepted by
form.field()
and
form.setValue()
.
InputArrayPath
Paths that point at an array. Accepted by
form.arrayField()
.
InputPathValue
The input type at a path.
OutputPath
/
OutputPathValue
The same, for the output shape.
ArrayItem
The item type of an array type.
```ts
import type { InputPath } from 'notform'
type FieldName = InputPath
```
Path types recurse to a depth of 10. Forms nested deeper than that lose path autocomplete beyond it.
## Type a reusable input component
A wrapper takes a field instance as a prop.
```vue
{{ label }}
```
```vue
```
Array items are fields too, so `ArrayItemInstance` is also accepted where a `FieldInstance` is expected.
If your linter reports `vue/no-mutating-props` for `v-model="field.model"`, it is a false positive. A field instance is a reactive object designed to be written through, and you are not replacing the prop.
## Pass a form to a child component
Type the prop with `UseFormInstance`, using the schema's type.
```vue
Save
Discard
```
```vue
```
Put the schema in its own file so both components can import its type. Prefer `UseFormInstance` over `FormInstance`. It is the public contract and excludes methods meant to stay internal.
## Schema requirements
`useForm` accepts a Standard Schema whose input **and** output are objects. This is what lets paths and `form.values` be keyed. A schema such as `z.string()` fails type checking.
## Next steps
- [Types reference](https://notformdocs.vercel.app/docs/reference/types) lists every exported type.
---
- **Page:** Field
- **Source:** https://notformdocs.vercel.app/docs/components/field
- **Description:** Expose a field instance to a renderless slot to bind its model and events to any control.
`Field` renders no element. It passes a field instance to its default slot so you can connect the value and validation events to native inputs or custom components.
## Usage
```vue
Email
```
`form.field(path)` returns the **same cached field** for the same path. Its `model` reads and writes `form.values`. Its `events` mark interaction and trigger validation according to the field's configuration.
## Props
The field created by `form.field(path)`, or an item from `arrayField.items`.
## Slot props
The default slot receives `field`.
Member
Type
Description
model
TValue
Two-way value. Assigning writes into
form.values
. Does not validate.
events
{ onBlur, onChange, onInput }
Handlers that mark interaction and trigger validation. Spread with
v-bind
.
path
string
Current dot-notation path. Follows an array item when its position changes. Use it for
id
and
for
.
id
symbol
Stable identity. Safe as a Vue key. Not valid as an HTML
id
.
errors
ValidationIssue[]
Issues reported at exactly this path.
isDirty
boolean
The value differs from the baseline value at this path.
isTouched
boolean
The field blurred, or a submit was attempted.
isValid
boolean
errors
is empty.
isValidating
boolean
The whole form, this field, or a field nested inside it is being validated.
validate()
() => Promise>
Validate this field now, skipping any pending debounce. Resolves with
{ value }
or
{ issues }
.
Extra attributes passed to `Field` arrive as additional slot props. Because `Field` renders no element, attach HTML attributes to the control inside the slot.
## Configure validation
Pass options as the second argument to `form.field(path, config)`. They are read when the field is first created. See [Validation](https://notformdocs.vercel.app/docs/guides/validation#configure-a-field).
```ts
const username = form.field('username', { debounce: 300, validateOn: { input: true } })
```
## Using a field without Field
The component is a convenience. A field instance works directly:
```vue
Email
```
## Related
- [Binding inputs](https://notformdocs.vercel.app/docs/guides/binding-inputs)
- [Custom components](https://notformdocs.vercel.app/docs/integrations/custom-components)
- [Form state](https://notformdocs.vercel.app/docs/guides/form-state#field-state)
---
- **Page:** FieldError
- **Source:** https://notformdocs.vercel.app/docs/components/field-error
- **Description:** Display the first validation message from a field, an array field, or an issue list.
`FieldError` displays the first message from an issue array, or from any object that has an `errors` array, such as a field, an array field, or an array item. It renders **no element** when there is no message.
## Usage
Pass a field directly:
```vue
Email
```
Or pass an issue array:
```vue
```
## Props
An issue array, or an object with an `errors` array. Only the first message is displayed.
Element or component that renders the message. Defaults to `span`.
Attributes other than `errors` and `as` are forwarded to the rendered element or component. Use `class`, `id`, `role`, and `aria-*` to match your styling and accessibility needs.
## Slot
The default slot receives `message`. Use it to wrap or decorate the text.
```vue
Check this value: {{ message }}
```
When `as` is a component, the message is passed as its default slot content. That component must render its default slot.
## Notes
- Which issues a field shows: exactly those at its own path. An issue on a parent path or without a path is not shown. See [Errors](https://notformdocs.vercel.app/docs/guides/errors).
- To display every message, render `field.errors` yourself.
- Pass `:errors="arrayField"` to show array-level issues such as a minimum item count.
## Related
- [Errors guide](https://notformdocs.vercel.app/docs/guides/errors)
---
- **Page:** ArrayField
- **Source:** https://notformdocs.vercel.app/docs/components/array-field
- **Description:** Render array items with stable identities and mutation methods that keep values, state, and errors aligned.
`ArrayField` renders no element. It exposes an array field instance through its default slot. Create the instance with `form.arrayField(path)`.
## Usage
```vue
Remove
Add tag
```
Use `item.id` as the Vue key. `index` and `path` change with position. `id` does not.
## Props
The array field created by `form.arrayField(path)`.
## Slot props
The default slot receives `arrayField`. Extra attributes passed to `ArrayField` arrive as additional slot props.
### Field members
An array field has the regular field members `model`, `events`, `path`, `id`, `errors`, `isDirty`, `isTouched`, `isValid`, `isValidating`, and `validate()`, with these array-specific semantics:
- `errors` and `isValid` refer to issues at the array's **own** path, such as a minimum item count.
- `isDirty` compares the whole array, so nested edits count.
- `isValidating` includes validation of fields nested in its items.
### Array members
Member
Description
items
Item instances, in current order.
append(value)
Add an item at the end.
prepend(value)
Add an item at the start.
insert(index, value)
Insert at an index. Clamped to
0..length
.
remove(index)
Remove an item and its state. Ignored if out of range.
move(from, to)
Move an item. Ignored if either index is out of range.
swap(indexA, indexB)
Swap two items. Ignored if either index is out of range.
update(index, value)
Replace an item in place. Keeps its id and touched state, drops its old errors. Ignored if out of range.
### Item members
Each entry in `items` is a field for the item's value. It has every field member listed above, plus:
Member
Description
index
The item's current position.
field(path, config?)
A field nested in the item, with a path relative to the item. Cached, and follows the item when it moves.
arrayField(path, config?)
An array field nested in the item.
## Behavior
- Values passed to `append`, `prepend`, `insert`, and `update` are deep-cloned.
- Every mutation counts as a change event on the array field. By default it re-validates the array's own path.
- Errors inside the array are re-pointed to follow their items on every mutation.
- Reset creates fresh item ids.
- A removed item, and anything created from it, becomes inert: mutations do nothing.
Change the array only through these methods. Direct mutation such as `form.values.tags.push('x')` bypasses identity and error tracking. See [Array fields](https://notformdocs.vercel.app/docs/guides/array-fields).
## Related
- [Array fields guide](https://notformdocs.vercel.app/docs/guides/array-fields)
- [Field](https://notformdocs.vercel.app/docs/components/field)
---
- **Page:** Array fields
- **Source:** https://notformdocs.vercel.app/docs/guides/array-fields
- **Description:** Build repeatable sections with stable item identity. Add, remove, reorder, validate, and nest arrays without losing state or errors.
Use `form.arrayField(path)` for an array of values that users add, remove, or reorder. `ArrayField` exposes the array and its items through a slot.
## Render and bind items
Each item is a field for its own value, with an `id`, an `index`, and `field()` and `arrayField()` for nested values.
```vue
Contact email
Remove
Add contact
```
Key the loop with `item.id`, never with the index. After an insertion or reorder, the same item has a different index, and an index key makes Vue reuse the wrong DOM and component state.
## What each part shows
- `arrayField.errors` holds issues at the array's own path, such as the minimum count. ` ` displays the first.
- `item.field('email')` exposes the issues at that item's `email` path. Paths passed to `item.field()` are relative to the item and typed from the item's shape.
- For an array of primitives, pass the item itself to `Field`: ``.
## Mutate through the array field
Use these methods for every structural change. They keep item identity, touched state, and errors aligned with the values.
Method
Effect
append(value)
Add an item at the end.
prepend(value)
Add an item at the start.
insert(index, value)
Insert at an index. Later items shift. The index is clamped to
0..length
.
remove(index)
Remove the item and its state. Later items shift down.
move(from, to)
Move an item to another index.
swap(indexA, indexB)
Exchange two items.
update(index, value)
Replace an item in place. It keeps its identity and touched state, and its old errors are dropped.
Details:
- Values passed to `append`, `prepend`, `insert`, and `update` are **deep-cloned**, so two items never share an object.
- An out-of-range index for `remove`, `move`, `swap`, or `update` is ignored.
- If the array does not exist yet, the first insertion creates it.
- Each mutation counts as a **change** event on the array field. By default this re-validates the array's own path, so a "minimum items" error appears or clears immediately. It does not validate the items' fields.
Do not change the array's structure with `form.values.contacts.push()`, `splice()`, `sort()`, or by replacing the array. Direct changes bypass the methods that keep item identity and error paths aligned. Item ids are reconciled by length only, so errors and touched state can end up on the wrong items. Writing to a single item's field through its `model` is fine.
## Identity, index, and path
Member
Stable across moves?
Use it for
item.id
Yes
The
v-for
key.
item.index
No, follows position
Passing to
remove
,
move
, and
swap
.
item.path
No, follows position
Input ids and labels, such as
contacts.2.email
.
Fields created from an item, such as `item.field('email')`, follow the item when it moves.
## Reorder with buttons
```vue
Up
Down
```
## Nested arrays
Each item can create nested fields and nested arrays. Paths are relative to the item and follow it as it moves.
```vue
Remove member
Add member
Remove group
Add group
```
When an inner array is removed along with its group, its fields are disposed. A stale reference to a removed item is inert: mutations do nothing and its `items` are empty.
## Errors follow items
When items are inserted, removed, moved, or swapped, existing errors are re-pointed to the item's new index. Removing an item drops its errors. `update` drops the replaced item's old errors. The schema's own array-level rules, such as `.min(1)`, stay on the array path.
## Reset
`form.reset()` restores the baseline array and gives every item a **fresh id**. The keyed list re-renders, so item components are rebuilt. This is expected: reset begins a new editing state from the baseline.
## Next steps
- [ArrayField](https://notformdocs.vercel.app/docs/components/array-field) is the component contract.
- [Validation](https://notformdocs.vercel.app/docs/guides/validation) covers configuring an array field's events.
---
- **Page:** useForm
- **Source:** https://notformdocs.vercel.app/docs/composables/use-form
- **Description:** Create a typed reactive form from a Standard Schema-compatible object schema. Full configuration and instance reference.
`useForm` creates the form instance that owns values, errors, field instances, and submit state. Call it in a Vue setup context (`
Username
Email
Create account
```
## How long server errors last
Error
Cleared when
A field error from
setError({ path })
That field validates again and the schema reports no issue there, for example on blur or when the user edits it. Also on
reset()
, and on the next submit.
A form-level error from
setError({ message })
The next submit, a
form.validate()
, or
reset()
.
Editing a field with a server error therefore removes the error as soon as the schema passes, which is usually right: the server's complaint was about the old value.
To keep a server error visible while the user edits, store it yourself and display it next to `FieldError`.
## Nested and array paths
A path is an array of segments. Use indices for array items.
```ts
form.setError({ message: 'Email already invited.', path: ['contacts', 1, 'email'] })
form.setError({ message: 'Invalid city.', path: ['address', 'city'] })
```
If the server returns dot paths, split them: `path: key.split('.')`. Numeric strings such as `'1'` match array indices.
## Notes
- `setError` with a path **replaces** errors at that path. Without a path it **appends**, so avoid calling it repeatedly for the same message.
- Errors thrown (not returned) from `onSubmit` are not caught by NotForm. Wrap risky calls in `try/catch`, as above. See [Submission and reset](https://notformdocs.vercel.app/docs/guides/submission-and-reset#errors-thrown-in-onsubmit).
- `isSubmitting` stays `true` until `onSubmit` settles, so the button stays disabled during the request.
---
- **Page:** Cross-field validation
- **Source:** https://notformdocs.vercel.app/docs/recipes/cross-field-validation
- **Description:** Validate one field against another, such as confirm password or date ranges, and keep the dependent field's error up to date.
Some rules involve more than one field: a confirmation must match, an end date must follow a start date. NotForm validates with your schema, so you write the rule in the schema and tell it which field should display the error.
## Put the rule on the schema, and set the path
An issue is shown by the field whose path matches the issue's `path`. Report the issue on the field you want to display it.
```ts
import { z } from 'zod'
const schema = z.object({
confirmPassword: z.string().min(1, 'Confirm your password.'),
password: z.string().min(8, 'Use at least 8 characters.'),
}).refine((values) => {
return values.password === values.confirmPassword
}, {
message: 'Passwords do not match.',
path: ['confirmPassword'],
})
```
Without `path`, the issue is form-level and no field displays it.
Some validators skip object-level rules while a field in the object is invalid, so the mismatch error may only appear after the individual fields pass. Check your validator's documentation if the cross-field message does not show when you expect it.
## Keep the dependent field up to date
Fields validate on their own events. If the user fixes `password` after `confirmPassword` showed an error, `confirmPassword` does not know to re-validate. Watch the source field and re-validate the dependent one.
```vue
Password
Confirm password
```
Passing the pre-created field instances to `Field` is fine. `Field` accepts any field instance.
## Date ranges
The same pattern applies to any dependent pair.
```ts
const schema = z.object({
endDate: z.string().min(1, 'Choose an end date.'),
startDate: z.string().min(1, 'Choose a start date.'),
}).refine((values) => {
return values.endDate >= values.startDate
}, {
message: 'End date must be on or after the start date.',
path: ['endDate'],
})
```
ISO date strings (`2026-11-02`) compare correctly as text. Watch `startDate` and re-validate `endDate`, exactly as above.
## Show a rule for the whole form
For a rule that is not about one field, omit `path` and show it as a form-level error. See [Errors](https://notformdocs.vercel.app/docs/guides/errors#show-form-level-errors).
---
- **Page:** Async validation
- **Source:** https://notformdocs.vercel.app/docs/recipes/async-validation
- **Description:** Check values against a server, such as username availability, with debounce, caching, and loading state.
Validators that support asynchronous rules work with NotForm, because validation is already asynchronous. The thing to design around is cost: **every field validation runs the entire schema**, so an async rule runs whenever any field validates.
## Example: username availability
```vue
Username
Checking...
Email
Create account
```
## Why each piece is there
Piece
Reason
debounce: 400
Waits for a pause in typing instead of validating on every keystroke. Blur and submit are never delayed.
validateOn: { input: true }
Validates while typing, not only on blur.
The promise cache
The email field's validation also runs the username rule. The cache makes that free for unchanged values.
The early
return true
Avoids a request for values the cheaper
min(3)
rule already rejects. The
min
message is still reported by that rule.
field.isValidating
true
while any validation that concerns this field runs. Use it for the spinner.
## Stale results
If the user types again while a lookup is in flight, the old result is discarded and cannot overwrite newer errors. Any value change makes every in-flight validation stale, and the change's own event starts a fresh one. See [Validation](https://notformdocs.vercel.app/docs/guides/validation#stale-results).
## Submit still validates everything
Submitting re-runs the whole schema, including the async rule, so a username that became taken after the last check is still caught. If the cache could hold outdated answers, clear it before submit or key it by time.
## Cache invalidation
The simple `Map` above lives for the component's lifetime. If availability can change during a session, expire entries after a short time, or clear the cache in `onSubmit`'s failure path.
---
- **Page:** Custom components
- **Source:** https://notformdocs.vercel.app/docs/integrations/custom-components
- **Description:** Connect any Vue input component to a NotForm field by mapping its value and events, and build reusable field wrappers.
Any control can work with NotForm. You only need to answer two questions about it:
1. **How does it take and emit its value?** Usually `v-model`.
2. **Which of its events mean "typed", "committed", and "left"?** These map to `onInput`, `onChange`, and `onBlur`.
## Choose the binding
The control...
Bind it like this
Forwards native
input
,
change
, and
blur
events
v-model="field.model" v-bind="field.events"
Emits only
update:modelValue
when a value is picked
v-model="field.model" @update:model-value="field.events.onChange"
Emits
update:modelValue
on every keystroke
v-model="field.model" @update:model-value="field.events.onInput"
Emits an event when focus leaves, such as
blur
,
focusout
, or
close
@blur="field.events.onBlur"
Use the event names from the component's own documentation.
## Which handler means what
- **onInput**: the value changed while the user is still editing, such as typing. By default it only validates if the field already shows an error.
- **onChange**: the user committed a value, such as selecting an option. It validates by default.
- **onBlur**: focus left the control. It marks the field touched and validates by default.
Pick the handler that matches the interaction. You can change what each one triggers with [`validateOn` and `validationMode`](https://notformdocs.vercel.app/docs/guides/validation#configure-a-field).
Keep `v-model` before the event binding. The value has to be written before `onChange` or `onInput` takes its snapshot for validation.
## Controls that emit a value
```vue
```
## Controls that forward native events
```vue
```
`field.events` contains `onBlur`, `onChange`, and `onInput`. If the component does not forward those events, bind the callbacks explicitly, as above.
## Controls that do not use v-model
If a control exposes a value prop and a callback instead, write through `field.model` yourself:
```vue
{
field.model = value
field.events.onChange()
}"
/>
```
Assign first, then call the event.
## A reusable field wrapper
Most apps wrap label, input, and error once. Accept a field instance as a prop.
```vue
{{ label }}
```
```vue
```
The parent keeps the form instance and passes only the field. Array items are fields too, so ` ` works inside `ArrayField`.
## Checklist for a new control
- [ ] Does `field.model` reflect the control's value type? Make sure the schema accepts that type (for example `boolean`, `string[]`, or a number).
- [ ] Does the control have an element that can take focus, so `onBlur` can fire?
- [ ] Does it have an accessible name? Use `field.path` for ids.
- [ ] Does it show `field.isValid` and the error message?
## Related
- [Binding inputs](https://notformdocs.vercel.app/docs/guides/binding-inputs)
- [Field](https://notformdocs.vercel.app/docs/components/field)
---
- **Page:** Editing existing data
- **Source:** https://notformdocs.vercel.app/docs/recipes/editing-existing-data
- **Description:** Load a saved record into a form, track unsaved changes, and make the saved result the new baseline.
An edit form starts from data that already exists. The key idea is the **baseline**: the values `isDirty` compares against and `reset()` returns to. Loaded data should become the baseline.
## When the data is ready before the form
If the record is available when the form is created, pass it as `initialValues`.
```ts
const { data: profile } = await useFetch('/api/profile') // Nuxt
const form = useForm({
initialValues: profile.value ?? { bio: '', displayName: '' },
async onSubmit(values) {
const saved = await $fetch('/api/profile', { body: values, method: 'PUT' })
form.reset(saved)
},
schema: profileSchema,
})
```
In plain Vue, wait to render the form until the data has loaded, then pass it down.
```vue
```
## When the data arrives after the form exists
Create the form with defaults and call `reset(values)` once the data arrives. This replaces the baseline, so `isDirty` is `false` right after loading.
```ts
const form = useForm({
initialValues: { bio: '', displayName: '' },
async onSubmit(data) {
const saved = await api.updateProfile(data)
form.reset(saved)
},
schema: profileSchema,
})
onMounted(async () => {
form.reset(await api.getProfile())
})
```
`reset(values)` also restores errors to the baseline, clears touched state, bumps `resetCount`, and gives array items fresh ids.
## Make the saved data the new baseline
After a successful save, call `form.reset(saved)` with what the server stored. The form then reports `isDirty === false`, and a later plain `reset()` returns to the saved version, not the original.
If the server returns exactly what you sent, `form.reset(data)` inside `onSubmit` is enough. Remember that `data` is the schema **output**. If your schema transforms values, reset with an object in the **input** shape instead.
## Warn about unsaved changes
```vue
```
## Show save state
```vue
{{ form.isSubmitting ? 'Saving...' : 'Save changes' }}
Discard changes
```
## Tips
- Keep every bound field present in the loaded object, using `''`, `false`, or `[]` where the server sends `null`. Missing keys reach the schema as `undefined`.
- Reload the baseline when the underlying record changes. For example,
```ts
watch(profile, (value) => {
form.reset(value)
})
```
- Input and output shapes can differ. `reset` and `initialValues` always use the **input** shape.
---
- **Page:** Nuxt UI
- **Source:** https://notformdocs.vercel.app/docs/integrations/nuxt-ui
- **Description:** Use NotForm with Nuxt UI inputs and form fields, including text, textarea, select, checkbox, checkbox group, radio group, switch, and array fields.
Nuxt UI provides styled inputs and a `UFormField` wrapper for labels and error text. NotForm owns the form state and validation, and Nuxt UI renders the controls.
## Setup
Install both modules and register them.
```ts [nuxt.config.ts]
export default defineNuxtConfig({
modules: ['@nuxt/ui', '@notform/nuxt'],
})
```
## Use NotForm's form, not UForm
Wrap your controls in NotForm's `Form`, not Nuxt UI's `UForm`. `UForm` has its own validation and submit pipeline. Using both makes two systems compete over the same state.
`UFormField` is safe to use on its own. It lays out the label, description, and error text.
## Choose the right binding
Bind the model with `v-model:model-value`, then report changes and blur to NotForm yourself. Text inputs report with `onInput`, and the other controls report with `onChange`.
Component
Binding
UInput
,
UTextarea
v-model="field.model"
,
@update:model-value="field.events.onInput"
and
@blur="field.events.onBlur"
USelect
,
UCheckbox
,
UCheckboxGroup
,
URadioGroup
,
USwitch
v-model="field.model"
,
@update:model-value="field.events.onChange"
and
@blur="field.events.onBlur"
## Show errors
`UFormField` renders error text from its `error` prop, so you do not need `FieldError` here. Pass it the first message of `field.errors` with a small helper:
```ts
import type { ValidationIssue } from 'notform'
function getFirstErrorMessage(errors?: ValidationIssue[]) {
return errors?.[0]?.message
}
```
Each section below shows the schema and the template it binds to. The snippets assume the `form`, `getFirstErrorMessage` and the items from the [full example](#full-example).
## Text input
```ts
title: z.string().min(5, 'Bug title must be at least 5 characters.')
.max(32)
```
```vue
```
## Textarea
`UTextarea` takes the same bindings as a text input. Because `field.model` is reactive, you can read it anywhere in the slot, which is how the character counter stays in sync.
```ts
description: z.string().min(20, 'Description must be at least 20 characters.')
.max(100)
```
```vue
{{ field.model?.length || 0 }}/100 characters
```
## Select
Pass the options through `items`.
```ts
language: z.string().min(1, 'Please select your spoken language.')
```
```vue
```
## Checkbox
```ts
responses: z.literal(true, 'Notifications are mandatory')
```
```vue
```
## Checkbox group
`UCheckboxGroup` binds the array of selected values directly through `v-model:model-value`.
```ts
tasks: z.array(z.string()).min(1, 'Please select at least one notification type.')
```
```vue
```
## Radio group
```ts
plan: z.string().min(1, 'You must select a subscription plan to continue.')
```
```vue
```
## Switch
```ts
twoFactor: z.boolean().refine((value) => {
return value === true
}, 'It is highly recommended to enable two-factor authentication.')
```
```vue
```
## Array fields
Use `ArrayField` for lists the user can grow, shrink, and reorder. Each item exposes `item.field('key')`, so you can wrap any of the inputs above in a `Field`.
```ts
emails: z.array(z.object({ address: z.email('Enter a valid email address.') }))
.min(2, 'Add at least two email addresses.')
.max(5, 'You can add up to 5 email addresses.')
```
```vue
Add Email Address
```
A few things to note:
- `arrayField.items` gives you each item's `id` and `index`. Use `item.id` as the `:key`, not the index, so inputs keep their state when you reorder or remove items.
- `arrayField.append`, `arrayField.remove` and `arrayField.move` change the list.
- Errors for a single item come from its `field`. Errors for the list as a whole, such as the `min` and `max` rules, come from `arrayField.errors`.
- Mirror the schema limits in the UI, as the **Add** button does with `arrayField.items.length >= 5`, so users can't reach an invalid state by clicking.
## Full example
This form combines every input above.
```vue
{{ field.model?.length || 0 }}/100 characters
Add Email Address
Reset
Submit
```
## Related
- [Custom components](https://notformdocs.vercel.app/docs/integrations/custom-components)
- [Nuxt module](https://notformdocs.vercel.app/docs/getting-started/nuxt-module)
---
- **Page:** Working with AI
- **Source:** https://notformdocs.vercel.app/docs/ai/working-with-ai
- **Description:** Give AI coding assistants accurate NotForm knowledge using llms.txt, per-page Markdown, and the NotForm agent skill.
AI assistants are fast at writing form code, and just as quick to invent APIs that do not exist. NotForm's API is small but specific, so an assistant working from memory will often reach for names from other libraries.
NotForm gives assistants three ways to work from the real documentation.
Resource
What it is
Best for
llms.txt
An index of every documentation page with a one-line description
Letting an assistant discover which pages exist
Page Markdown
Any documentation page as clean Markdown
Loading exactly the pages a task needs
llms-full.txt
The whole documentation in one file
Broad tasks, or tools that cannot follow links
The agent skill
A short set of instructions that routes an agent to the right pages
Making the agent do all of the above on its own
## Quick start
1. **Install the skill.** Copy the `notform` skill into your project so your assistant loads it automatically. See [Agent skill](https://notformdocs.vercel.app/docs/ai/agent-skill#install).
2. **Ask for what you need.** Describe the form in plain words. The skill makes the assistant read the matching pages before it writes code.
3. **Review against the docs.** Ask the assistant which pages it used. If something it wrote is not in those pages, it is a guess.
No skill support? Paste `https://notformdocs.vercel.app/llms.txt` into the conversation and ask the assistant to read the pages relevant to your task before writing code.
## What assistants get right and wrong
Assistants do well when the docs are in front of them. These are the mistakes the documentation and skill are designed to prevent:
- using `path` props or form context from NotForm v3 instead of explicit field instances;
- using `index` instead of `item.id` as an array key;
- mutating arrays directly instead of through `ArrayField` methods;
- expecting `form.setValue` or `field.model` writes to validate;
- passing field configuration on every render and expecting it to apply;
- mixing NotForm with another form library on the same form.
## Next steps
- [llms.txt and Markdown pages](https://notformdocs.vercel.app/docs/ai/llms-txt)
- [The NotForm agent skill](https://notformdocs.vercel.app/docs/ai/agent-skill)
---
- **Page:** Migrating from NotForm v3
- **Source:** https://notformdocs.vercel.app/docs/migrations/notform-v3
- **Description:** Update NotForm v3 code to v4: renamed APIs, explicit field instances, moved validation config, and array fields.
NotForm v4 changes the public Vue API. This guide updates forms written with the v3 names `useNotForm`, `NotForm`, `NotField`, `NotArrayField`, and `NotMessage`.
Check your installed version first. If `notform` in your `package.json` is `3.x`, the rest of these docs describe the version you are migrating to.
## 1. Rename the APIs
NotForm v3
NotForm v4
useNotForm
useForm
The core package is still `notform`. For Nuxt, use `@notform/nuxt`. Older instructions may reference `notform-nuxt`, so replace that dependency. In Vue, update imports from `notform`. In Nuxt, the module auto-imports the v4 names.
Rename everywhere with a project-wide search and replace on whole words, then fix the structural changes below.
## 2. Pass explicit field instances
In v3, `` provided form context to descendants, so a field could be declared with only a `path`. In v4, `Form` renders the native form but provides no context. Create each field on the form instance and pass it to `Field`.
```vue [v3.vue]
```
```vue [v4.vue]
```
The v4 `Field` slot exposes one `field` object. Use `field.model`, `field.events`, `field.path`, and `field.errors` instead of the top-level slot props `events`, `path`, and `errors`. Binding `field.model` also replaces writing to `form.values.email` directly, though that still works.
## 3. Move validation config to the field
v3 field components accepted options such as `validateOn`, `validationMode`, and `debounce` as props. In v4, pass a `FieldConfig` as the second argument when the field is first created.
```ts
const username = form.field('username', {
debounce: 300,
validateOn: { input: true },
validationMode: 'lazy',
})
```
The keys of `validateOn` are `blur`, `change`, and `input`. A field is cached by path, so later calls with different options do not replace the first. See [Validation](https://notformdocs.vercel.app/docs/guides/validation#configure-a-field).
## 4. Update array fields
v3 `NotArrayField` was declared by path. In v4, create an explicit array field, and use `item.id` as the key.
```vue
Remove
Add
```
Each item provides `index`, `path`, `field()`, and `arrayField()` for nested values. Mutations are methods on `arrayField`. See [Array fields](https://notformdocs.vercel.app/docs/guides/array-fields).
## 5. Check the rest of the form
- `Form` requires a `form` prop and does not inject it into descendants.
- `FieldError` takes an issue array or a field-like value through `errors`. It no longer resolves a path from form context.
- `onSubmit` remains a `useForm` option, and `Form` handles native submit and reset.
- `useForm` returns the public API in [useForm](https://notformdocs.vercel.app/docs/composables/use-form). Do not rely on instance members that are not listed there.
## Migration checklist
- [ ] Dependencies updated (`notform`, and `@notform/nuxt` instead of `notform-nuxt`).
- [ ] All `useNotForm`, `NotForm`, `NotField`, `NotArrayField`, and `NotMessage` renamed.
- [ ] Every `Field` receives `:field="form.field(path)"` instead of a `path` prop.
- [ ] Slot props switched to `field.model`, `field.events`, `field.path`, and `field.errors`.
- [ ] `FieldError` receives `:errors="field"`.
- [ ] Validation options moved into `form.field(path, config)`.
- [ ] Array fields use `form.arrayField(path)`, `item.id` keys, and array methods.
- [ ] Type check and lint pass. Mistyped paths are now compile errors.
## Next steps
- [Quickstart](https://notformdocs.vercel.app/docs/getting-started/quickstart) shows a complete v4 form.
- [Core concepts](https://notformdocs.vercel.app/docs/getting-started/core-concepts) explains the model that v4 makes explicit.
---
- **Page:** Multi-step forms
- **Source:** https://notformdocs.vercel.app/docs/recipes/multi-step-forms
- **Description:** Split one form into steps, validate each step before continuing, and submit everything at the end.
A multi-step form is one form whose schema changes as the user moves through it. Each step renders a subset of the fields and validates against its own schema. Because values live in the form, they persist as users move between steps.
## Approach
1. Define one schema per step. Each schema extends the previous one, so the last schema describes the whole form.
2. Pick the active schema from the current step index and pass it to `useForm`.
3. Render a `type="submit"` button on every step. Submit validates the active schema, then calls `onSubmit`.
4. In `onSubmit`, advance to the next step. On the final step, send the values instead.
## Example
```vue
Name
Email
Plan
{{ plan }}
Previous
{{ isLastStep ? 'Finish' : 'Next' }}
```
## Why this works
- **Values persist.** `form.values` holds every step's data. Fields on hidden steps simply are not rendered, but their values remain.
- **Step validation is scoped.** The form validates against `activeSchema`, so fields from later steps cannot produce errors early.
- **Next needs no validity check.** `onSubmit` runs only after the active schema passes, so advancing the step there is enough.
- **The final submit is authoritative.** The last schema extends every earlier one, so it validates every field and `values` contains everything the user entered. Every field counts as touched.
## Notes
- Give every field a starting value, including steps the user has not reached. `undefined` is correct for a choice with no default, as with `plan`.
- Do not gate navigation on `form.isValid`. It is `true` before any validation has run, and validation is async, so reading it right after a click can let an invalid step through. Let `onSubmit` do the gating, as the example does.
- Next is a submit button, so pressing Enter in a field behaves like clicking Next: it validates the active step and advances. Previous is `type="button"`, so it never submits or validates.
- To keep step state in the URL, store `stepIndex` in a route query. NotForm does not need to know about it.
## With Pinia
The same approach works when the form lives in a Pinia store. Create the form in the store, render each step as its own component, and wrap the active step in `` so step components are cached while the user moves between them.
This helps when steps are large enough to live in separate files, or when something outside the form, such as a progress sidebar or a route guard, needs to read the current step.
```ts [stores/signup.ts]
import { useForm } from 'notform'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
// `profileStepSchema` and `planStepSchema` are the same as above.
const stepSchemas = [profileStepSchema, planStepSchema]
export const useSignUpStore = defineStore('sign-up', () => {
const stepIndex = ref(0)
const isFirstStep = computed(() => {
return stepIndex.value === 0
})
const isLastStep = computed(() => {
return stepIndex.value === stepSchemas.length - 1
})
const activeSchema = computed(() => {
return stepSchemas[stepIndex.value]
})
const form = useForm({
initialValues: { email: '', name: '' },
async onSubmit(values) {
if (!isLastStep.value) {
stepIndex.value++
}
// Send `values` to your API.
},
schema: activeSchema,
})
function goToPreviousStep() {
if (!isFirstStep.value) {
stepIndex.value--
}
}
return { form, goToPreviousStep, isFirstStep, isLastStep, stepIndex }
})
```
```vue [components/sign-up-form.vue]
Previous
{{ signUp.isLastStep ? 'Finish' : 'Next' }}
```
Each step component reads the store and renders its own `Field`s, exactly like the single-file example.
- **Values live in the store, not in KeepAlive.** The form keeps every step's values either way. `KeepAlive` only preserves each step's component state, such as focus, scroll position, and local UI state, and skips re-mounting.
- **KeepAlive takes one child at a time.** Switching with `` fits this naturally.
- **The store outlives the page.** Reset the form and `stepIndex` when the wizard finishes or the user leaves, or the next visit starts mid-way.
---
- **Page:** llms.txt and Markdown pages
- **Source:** https://notformdocs.vercel.app/docs/ai/llms-txt
- **Description:** What the NotForm llms.txt, llms-full.txt, and per-page Markdown endpoints contain, and how to use each with an AI tool.
[`llms.txt`](https://llmstxt.org) is a convention for publishing documentation in a form that language models can read. The NotForm docs publish three views.
## The index
`llms.txt` is a short Markdown file that lists the documentation pages with a short description of each. It is the cheapest way for an assistant to learn what exists, and it is small enough to load at the start of any task.
Typical use: give the URL to an assistant and ask it to pick the pages it needs.
> Read [https://notformdocs.vercel.app/llms.txt](https://notformdocs.vercel.app/llms.txt), then read the pages about array fields and validation, and build a repeatable contacts form.
## Read a single page
Add `.md` to a documentation page's path to get that page as Markdown. Use the section and page names from the URL in your browser.
```text
https://notformdocs.vercel.app/docs/components/field.md
https://notformdocs.vercel.app/docs/guides/validation.md
https://notformdocs.vercel.app/docs/recipes/server-errors.md
```
Loading focused pages keeps the assistant's context small and its answers specific. This is the preferred way to use the docs with an AI tool.
## The full documentation
`llms-full.txt` contains the whole documentation in a single file. Use it when:
- the task spans many sections, such as a migration;
- the tool cannot follow links or fetch several pages; or
- you want to load everything once and ask many questions.
It is large. For a single question, the index plus one or two pages is faster and cheaper.
## Which should I use?
Situation
Use
Build or fix one feature
Index, then the relevant pages
Migrate from NotForm v3
The migration page
, then the pages for each API it names
Tool only accepts one URL or file
llms-full.txt
You already know the page
The page's
.md
URL
Repeated work in one project
The agent skill
## Using the files with common tools
- **Chat assistants:** paste the URL into the conversation and ask the assistant to read it.
- **Editors and coding agents:** add the URL as a documentation source in the tool's settings, or reference it in your project's agent instructions.
- **Your own scripts:** fetch the `.md` URLs directly. They are plain Markdown.
## Keep in mind
- The files describe the **latest published** documentation. If your project pins an older NotForm version, check that the API you rely on exists in your installed package.
- The index lists pages, not behavior. Always read the page before writing code against it.
---
- **Page:** ShadcnVue
- **Source:** https://notformdocs.vercel.app/docs/integrations/shadcn-vue
- **Description:** Use NotForm with ShadcnVue inputs, including text, textarea, select, checkbox, radio group, switch, and array fields, and avoid the Field and FieldError naming conflict.
ShadcnVue copies component source into your project, so the exact props depend on the version you installed. The mappings below follow the pattern in [Custom components](https://notformdocs.vercel.app/docs/integrations/custom-components). Check your local component files if a binding differs.
## Resolve the naming conflict
Recent ShadcnVue releases include `Field` and `FieldError` components. NotForm exports components with the same names. Rename one set at the import.
```vue [Vue]
```
```ts [Nuxt]
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@notform/nuxt', 'shadcn-vue/nuxt'],
notform: {
aliases: {
Field: 'NotField',
FieldError: 'NotFieldError'
},
},
})
```
The examples below use `NotField` for NotForm and `Field` for ShadcnVue.
## Choose the right binding
Every `NotField` slot gives you a `field` with a `model`, a set of `events`, and the validation state (`isValid` and `errors`). How you connect them depends on what the ShadcnVue component emits.
Component
Binding
Input
,
Textarea
,
InputGroupInput
,
InputGroupTextarea
v-model="field.model"
and
v-bind="field.events"
Select
v-model="field.model"
and
@update:model-value="field.events.onChange"
, with
@blur="field.events.onBlur"
on
SelectTrigger
Checkbox
,
Switch
,
RadioGroup
v-model="field.model"
,
@update:model-value="field.events.onChange"
and
@blur="field.events.onBlur"
Checkbox group (array of values)
:model-value
and a handler that updates
field.model
, then calls
field.events.onChange()
Components that only emit `update:modelValue` don't fire native `input` or `change` events, so `v-bind="field.events"` does nothing for them. Report changes and blur explicitly instead.
Each section below shows the template and the schema it binds to. The snippets assume the `form` and the imports from the [full example](#full-example).
## Text input
```ts
title: z
.string()
.min(5, 'Bug title must be at least 5 characters.')
.max(32, 'Bug title must be at most 32 characters.')
```
```vue
Bug Title
```
`field.errors` is an array of issues that each have a `message`, which is the shape ShadcnVue's `FieldError` renders.
## Textarea
Use `InputGroupTextarea` inside an `InputGroup` when you want an addon, such as a character counter. It takes the same bindings as a text input.
```ts
description: z
.string()
.min(20, 'Description must be at least 20 characters.')
.max(100, 'Description must be at most 100 characters.')
```
```vue
Description
{{ field.model?.length || 0 }}/100 characters
Include steps to reproduce, expected behavior, and what actually
happened.
```
`field.model` is reactive, so you can read it anywhere in the slot, which is how the counter stays in sync.
## Select
The trigger is the focusable element, so put the `id` and report blur from it.
```ts
language: z.string().min(1, 'Please select your spoken language.')
```
```vue
Spoken Language
{{ language.label }}
```
Keep an empty string as the initial value so the placeholder shows until the user picks something.
## Checkbox
Bind the checkbox with `v-model:model-value`, then report changes and blur to NotForm yourself: call `field.events.onChange` from `update:model-value` and `field.events.onBlur` from `blur`.
```ts
responses: z.literal(true, 'Notifications are mandatory')
```
```vue
Push notifications
```
If the checkbox can be in an indeterminate state, its value is not a plain boolean, so your schema must accept that.
## Checkbox group
When several checkboxes share one field, store the selected values in an array. The checkbox doesn't bind to `field.model` directly. Instead, derive its checked state and write the new array back yourself.
```ts
tasks: z
.array(z.string())
.min(1, 'Please select at least one notification type.')
```
```vue
Tasks
Get notified when tasks you've created have updates.
{
field.model = checked === true
? [...(field.model ?? []), task.id]
: (field.model ?? []).filter(value => value !== task.id)
field.events.onChange()
}"
/>
{{ task.label }}
```
Give each checkbox a unique `id` by appending the item's id to `field.path`, and call `field.events.onChange()` after you update the model so validation runs against the new value.
## Radio group
Bind the group, not the items. Each `RadioGroupItem` only needs its own `value`.
```ts
plan: z.string().min(1, 'You must select a subscription plan to continue.')
```
```vue
Plan
You can upgrade or downgrade your plan at any time.
{{ plan.title }}
{{ plan.description }}
```
## Switch
`Switch` follows the same pattern as `Checkbox`: a boolean model plus explicit change and blur handlers.
```ts
twoFactor: z.boolean().refine((value) => {
return value === true
}, 'It is highly recommended to enable two-factor authentication.')
```
```vue
Multi-factor authentication
Enable multi-factor authentication to secure your account.
```
## Array fields
Use `ArrayField` for lists the user can grow, shrink, and reorder. Each item exposes `item.field('key')`, so you can wrap any of the inputs above in a `NotField`.
```ts
emails: z
.array(z.object({
address: z.email('Enter a valid email address.'),
}))
.min(2, 'Add at least two email addresses.')
.max(5, 'You can add up to 5 email addresses.')
```
```vue
Email Addresses
Add up to 5 email addresses where we can contact you.
Add Email Address
```
A few things to note:
- `arrayField.items` gives you each item's `id` and `index`. Use `item.id` as the `:key`, not the index, so inputs keep their state when you reorder or remove items.
- `arrayField.append`, `arrayField.remove` and `arrayField.move` change the list. Set `type="button"` on every button that calls them so they don't submit the form.
- Errors for a single item come from its `field`. Errors for the list as a whole, such as the `min` and `max` rules, come from `arrayField.errors`.
- Mirror the schema limits in the UI, as the **Add** button does with `arrayField.items.length >= 5`, so users can't reach an invalid state by clicking.
## Full example
This form combines every input above. It assumes `vue`, `zod`, `@lucide/vue` and the ShadcnVue components you've added to your project.
```vue
Bug Title
Description
{{ field.model?.length || 0 }}/100 characters
Include steps to reproduce, expected behavior, and what actually
happened.
Spoken Language
For best results, select the language you speak.
Auto
{{ language.label }}
Responses
Get notified for requests that take time, like research or image
generation.
Push notifications
Tasks
Get notified when tasks you've created have updates.
{
field.model = checked === true
? [...(field.model ?? []), task.id]
: (field.model ?? []).filter(value => value !== task.id)
field.events.onChange()
}"
/>
{{ task.label }}
Plan
You can upgrade or downgrade your plan at any time.
{{ plan.title }}
{{ plan.description }}
Multi-factor authentication
Enable multi-factor authentication to secure your account.
Email Addresses
Add up to 5 email addresses where we can contact you.
Add Email Address
Reset
Submit
```
## Related
- [Custom components](https://notformdocs.vercel.app/docs/integrations/custom-components)
- [Nuxt module aliases](https://notformdocs.vercel.app/docs/getting-started/nuxt-module#avoid-name-conflicts)
---
- **Page:** Agent skill
- **Source:** https://notformdocs.vercel.app/docs/ai/agent-skill
- **Description:** Install and use the NotForm agent skill, which routes AI coding assistants to the right documentation instead of answering from memory.
The NotForm agent skill is a small instruction file for AI coding assistants. It does **not** contain API documentation. Its job is to make the assistant look things up: identify your setup, find the relevant pages, read them, and only then write code.
## Why a skill instead of more docs
A copy of the API inside the skill would go stale and compete with these docs. The skill instead holds the part that does not change: where to look, in what order, and the few rules that assistants break most often. The documentation stays the single source of truth.
## Install
The skill lives in the NotForm repository at [`.agents/skills/notform/SKILL.md`](https://github.com/favorodera/notform/blob/main/.agents/skills/notform/SKILL.md). To use it in your project, copy the `notform` folder into the skills directory your tool reads.
```text
your-project/
└── .agents/
└── skills/
└── notform/
└── SKILL.md
```
Some tools use a different folder. For example, Claude Code reads project skills from `.claude/skills/`. Check your tool's documentation for the location it expects, and place the `notform` folder there.
Commit the folder so everyone working on the project gets the same behavior.
You can also use the [`skills`](https://www.skills.sh/favorodera/notform/notform) CLI to install it. It supports more than 35 agents, including Cursor, Claude Code, Codex, Windsurf, and Cline.
## Use it
The skill activates when you ask an assistant to build, debug, or migrate a NotForm form. It is also user-invocable, so in tools that support slash commands you can run it by name:
```text
/notform
```
Then describe the task:
> Build a registration form with username, email, and a repeatable list of phone numbers. Show server errors under each field.
## What the skill makes the assistant do
1. **Identify your stack and version.** It checks whether the project uses `notform` (Vue) or `@notform/nuxt` (Nuxt), and which version.
2. **Handle v3 first.** If the project is on NotForm 3.x, it reads the [migration guide](https://notformdocs.vercel.app/docs/migrations/notform-v3) before anything else.
3. **Read the index.** It loads [`llms.txt`](https://notformdocs.vercel.app/docs/ai/llms-txt) to see which pages exist.
4. **Load only what the task needs.** It maps the task to pages, for example "validation or errors" to the [Validation](https://notformdocs.vercel.app/docs/guides/validation) and [Errors](https://notformdocs.vercel.app/docs/guides/errors) guides, and fetches those as Markdown.
5. **Write from the pages.** If something is not documented, it says so instead of inventing options.
## Rules the skill reinforces
These are the mistakes assistants make most, and each is explained in the docs.
- Create fields with `form.field(path)` and `form.arrayField(path)`, and pass the instance to `Field` or `ArrayField`. See [Field](https://notformdocs.vercel.app/docs/components/field).
- Bind `field.model` and spread `field.events`, with `v-model` first. See [Binding inputs](https://notformdocs.vercel.app/docs/guides/binding-inputs).
- Schema **input** is what users edit. Schema **output** is what `onSubmit` receives. See [Schema input and output](https://notformdocs.vercel.app/docs/guides/schema-input-output).
- Give `initialValues` for every bound field.
- Use `item.id` as an array key, and change arrays only through their methods. See [Array fields](https://notformdocs.vercel.app/docs/guides/array-fields).
- Writes through `field.model` and `form.setValue` do not validate.
- Field configuration is read once, when the field is created. See [Validation](https://notformdocs.vercel.app/docs/guides/validation#configure-a-field).
- NotForm should be the only owner of a form's state. Do not combine it with another form library on the same form.
## Example prompts
Goal
Prompt
New form
"Create a checkout form with NotForm and Zod. Validate on blur and show errors with accessible markup."
Integration
"Wire this form to our ShadcnVue inputs. Handle the
Field
name conflict."
Debugging
"My error message never clears after I fix the input. Find out why using the NotForm docs."
Migration
"Migrate this component from NotForm v3 to v4."
Review
"Review this form against the NotForm docs and list anything that is guessed."
## Limits
- The skill needs the assistant to be able to fetch URLs. Without network access, it asks you for the relevant pages instead of guessing.
- The skill is a router. If the docs and your installed package disagree, trust the package's public exports and tell the maintainers.
## Related
- [llms.txt and Markdown pages](https://notformdocs.vercel.app/docs/ai/llms-txt)
- [Working with AI](https://notformdocs.vercel.app/docs/ai/working-with-ai)