Enhanced form components for Nuxt UI with @formwerk/core integration. This module bridges the gap between Formwerk's powerful form validation and state management with Nuxt UI's beautiful form components.
The API is settled. USchemaForm, USchemalessForm, UFormRoot, UFormField, UFormGroup, UFormRepeater and useFormRoot are the supported surface, and it is considered feature complete — new work should be additive.
It is still versioned 0.x for one reason: @formwerk/core is pre-1.0. This module re-exports formwerk's types directly (FormReturns, ConsumableData, IssueCollection, GenericFormSchema, and the repeater's Iteration), so a formwerk major would change this package's public types no matter how stable its own design is. Promising semver on a surface we do not own would be a promise we could not keep. 1.0.0 follows formwerk's.
In practice that means breaking changes are expected only where @formwerk/core or @nuxt/ui force them. Two things worth knowing regardless:
- This module reaches into
@nuxt/uiinternals that are not public API — its form event bus anduseFormFieldinjection keys — against a^4.0.0peer range. A Nuxt UI minor can, in principle, break the bridge. UFormis not ours. It is Nuxt UI's own component, untouched. OnlyUFormFieldis overridden.
- Formwerk Integration - Seamless integration with @formwerk/core for advanced form validation
- Familiar Field API -
UFormFieldkeeps Nuxt UI's name and props, so templates need no rewriting - Field-level Validation - Granular validation control with error message handling
- State Tracking - Track touched, blurred, and dirty states per field
- Flexible Validation Strategies - Configure when validation occurs (on blur, on input, etc.)
- Form Repeater - Built-in support for dynamic array fields with add/remove/reorder
- Auto-import - Components are automatically available in your app
- TypeScript - Full type safety out of the box
Install the module and its peer dependencies:
pnpm add nuxt-ui-formwerk @formwerk/coreNote: This module requires
@formwerk/coreand@nuxt/uias peer dependencies. Make sure they are installed in your project.
Add the module to your nuxt.config.ts:
export default defineNuxtConfig({
modules: ["@nuxt/ui", "nuxt-ui-formwerk"],
})That's it! You can now use enhanced form components in your Nuxt app.
Formwerk forms are opt-in: you choose a formwerk root (USchemaForm, USchemalessForm or UFormRoot) where you want one. Nuxt UI's own UForm is left alone and keeps working for forms that don't need formwerk.
The one component this module does override is UFormField. The original stays reachable as NuxtUiFormField.
| Component | Origin | Purpose |
|---|---|---|
USchemaForm |
new | Self-contained root, driven by a Standard Schema |
USchemalessForm |
new | Self-contained root, shape from initialValues |
UFormRoot |
new | Adopts a form you created with useForm() |
UFormField |
overrides | Field wired to formwerk state and validation |
UFormGroup |
new | Nested field grouping |
UFormRepeater |
new | Dynamic array fields |
UForm |
untouched | Nuxt UI's own form — not formwerk-aware |
NuxtUiFormField |
renamed | Nuxt UI's original field |
The module automatically uses the same prefix as your Nuxt UI configuration (default: U).
Important
UFormField requires one of those roots. Inside a plain Nuxt UI <UForm> it would hold its value in formwerk while the form read from state — submitting nothing — so it throws instead. Use <NuxtUiFormField> for plain Nuxt UI fields.
The module bridges Nuxt UI's form system with formwerk by intercepting and coordinating events between both systems:
-
Dual Event Buses - the form root creates two event buses:
- A Nuxt UI form bus (
form-{id}) that Nuxt UI input components emit to - A formwerk bus (
formwerk-form-{id}) for internal state tracking
- A Nuxt UI form bus (
-
Event Interception -
UFormFieldlistens to the Nuxt UI form bus and intercepts events (blur,change,input,focus) emitted by Nuxt UI input components. These events are translated into formwerk state updates (setBlurred,setTouched). -
State Propagation - When formwerk field state changes (touched, blurred, dirty),
UFormFieldemits events on the formwerk bus, which the form root listens to for tracking field-level state across the entire form.
This architecture allows Nuxt UI's native input components to work seamlessly with formwerk's validation and state management without requiring any modifications to the input components themselves.
USchemaForm creates and owns its own formwerk form, so you never call useForm() yourself. Because useForm() provides on the calling component, owning it in script setup would limit you to one form per component — USchemaForm lifts that limit, so you can put several forms in a single component.
Use USchemaForm when you have a Standard Schema (zod, valibot, …) to validate against — the schema drives all type inference for values, initialValues, and the submitted data.
<script setup lang="ts">
import { z } from "zod"
import type { ConsumableData } from "@formwerk/core"
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
})
function onSubmit(data: ConsumableData<z.infer<typeof schema>>) {
return $fetch("/api/sign-in", { method: "POST", body: data.toJSON() })
}
</script>
<template>
<USchemaForm :schema="schema" @submit="onSubmit" #="{ values, form }">
<UFormField name="email" label="Email" #="{ model }">
<UInput v-bind="model" />
</UFormField>
<UButton type="submit" label="Sign in" :loading="form.isSubmitting.value" />
</USchemaForm>
</template>| Prop | Type | Default | Description |
|---|---|---|---|
schema |
Standard Schema | required | Drives all type inference. Read once at setup — see Gotchas. |
initialValues |
MaybeGetter<MaybeAsync<Values>> |
- | Initial values. Object, sync getter, or async getter are all supported. |
as |
string | Component |
'form' |
Native element to render as. Use a non-form element to avoid invalid nested <form> markup. See the note below. |
id |
string |
auto-generated | Form identifier. Two forms sharing an explicit id share event buses. |
showErrorsOn |
'touched' | 'blur' | 'dirty' |
'blur' |
When field errors become visible. |
validateOnInputDelay |
number |
300 |
Debounce, in ms, that Nuxt UI inputs apply before emitting their input event. Matches Nuxt UI's default. |
disabled |
boolean |
false |
Disables every field, and strips disabled paths out of the submitted data. |
initialTouched |
TouchedSchema<TInput> |
- | Marks fields as touched on mount. |
initialDirty |
DirtySchema<TInput> |
- | Marks fields as dirty on mount. |
Note
showErrorsOn is deliberately not called validateOn. Nuxt UI's forms have a validateOn prop meaning something different — an array of the DOM events that trigger validation — and reusing the name silently swallowed the array form. Here the value is a single field state, and formwerk owns when validation actually runs.
as is meant for native elements: novalidate is only applied when as is the string "form", because there's no way to tell what a component renders. Pass a component that renders its own <form> and setting novalidate on it is your responsibility — otherwise native constraint validation swallows the submit event and @submit/@error never emit.
form- The full formwerk form API (values,isSubmitting,handleSubmit,setValue,reset, …)values- Current form values (same asform.values)blurredFields- Set of field names that have been blurredtouchedFields- Set of field names that have been toucheddirtyFields- Set of field names with modified values
submit-(data: ConsumableData<TOutput>), emitted after a successfulhandleSubmit. Calldata.toJSON()to get the plain validated object.error-(issues: IssueCollection[]), emitted when the submit attempt fails validation.
@submit is a Vue emit, and Vue discards whatever a listener returns — so isSubmitting covers validation only, not your handler's own async work. If you need a loading state that spans an API call, drive the submit with form.handleSubmit(async …) from the slot prop or a template ref instead; that's formwerk's own API, and it awaits your callback before clearing isSubmitting.
Via ref/useTemplateRef: the entire formwerk form API flattened (values, isSubmitting, handleSubmit, setValue, reset, …), plus blurredFields, touchedFields, dirtyFields.
The schema-free counterpart to USchemaForm, for forms that don't validate against a Standard Schema. Like USchemaForm, it creates its own form, so it isn't limited to one form per component.
Because there's no schema, initialValues is the only place the form's shape can be inferred from.
An inline object literal is enough — no type declaration needed, and values is still fully typed:
<template>
<USchemalessForm :initial-values="{ email: '', password: '' }" #="{ values, form }">
<UFormField name="email" label="Email" #="{ model }">
<UInput v-bind="model" />
</UFormField>
<UButton type="submit" label="Sign in" :loading="form.isSubmitting.value" />
</USchemalessForm>
</template>If you want a reusable named shape, declare it with type, not interface — an interface is rejected, see Gotchas:
<script setup lang="ts">
type Credentials = {
email: string
password: string
}
const initialValues: Credentials = { email: "", password: "" }
</script>
<template>
<USchemalessForm :initial-values="initialValues" #="{ values }">{{ values.email }}</USchemalessForm>
</template>| Prop | Type | Default | Description |
|---|---|---|---|
initialValues |
TInput | (() => TInput) |
- | Initial values. Only place the shape can be inferred from — object or sync getter, see Gotchas. |
as |
string | Component |
'form' |
Native element to render as. Use a non-form element to avoid invalid nested <form> markup. See USchemaForm's note. |
id |
string |
auto-generated | Form identifier. Two forms sharing an explicit id share event buses. |
showErrorsOn |
'touched' | 'blur' | 'dirty' |
'blur' |
When field errors become visible. See USchemaForm's note on the name. |
validateOnInputDelay |
number |
300 |
Debounce, in ms, that Nuxt UI inputs apply before emitting their input event. |
disabled |
boolean |
false |
Disables every field, and strips disabled paths out of the submitted data. |
initialTouched |
TouchedSchema<TInput> |
- | Marks fields as touched on mount. |
initialDirty |
DirtySchema<TInput> |
- | Marks fields as dirty on mount. |
form- The full formwerk form API (values,isSubmitting,handleSubmit,setValue,reset, …)values- Current form values (same asform.values)blurredFields- Set of field names that have been blurredtouchedFields- Set of field names that have been toucheddirtyFields- Set of field names with modified values
submit-(data: ConsumableData<TInput>), emitted after a successfulhandleSubmit. Calldata.toJSON()to get the plain validated object.error-(issues: IssueCollection[]), emitted when the submit attempt fails validation.
As with USchemaForm, isSubmitting covers validation only — Vue discards a listener's return value. For a loading state that spans async submit work, use form.handleSubmit(async …) from the slot prop or a template ref.
Via ref/useTemplateRef: the entire formwerk form API flattened (values, isSubmitting, handleSubmit, setValue, reset, …), plus blurredFields, touchedFields, dirtyFields.
These were all found experimentally — expect to hit them cold otherwise.
- Declare shapes with
type, notinterface(USchemalessForm). Formwerk'sFormObjectisRecord<string, unknown>, and TypeScript gives interfaces no implicit index signature, so aninterfaceis rejected with a confusing error. Use atypealias instead. - Async initial values need
USchemaForm. OnUSchemalessForm,:initial-valuesis the only place the shape can be inferred from, so an object or a sync getter works, but an async getter is rejected at compile time. UseUSchemaForminstead — the schema supplies the shape, so async is fine there. :schemais read once at setup. Formwerk closes over it, so swapping the schema at runtime does nothing — use:keyonUSchemaFormto force a remount when the schema changes.- Use
useTemplateRef, notComponentExposed. The usualComponentExposed<typeof Comp>advice for generic components degrades the type to{}here. A plainuseTemplateRef("form")keeps the exposed API fully typed. isSubmittingcovers validation only when you submit via@submit. Vue discards whatever an emit listener returns, so anasynchandler keeps running afterisSubmittinghas flipped back tofalse, and a:loadingbutton flashes instead of staying lit. Drive async submits withform.handleSubmit(async …)— off the slot prop or a template ref — andisSubmittingstays true for the whole callback.
Also worth knowing:
asavoids invalid nested<form>markup. RenderUSchemaForm/USchemalessFormas a non-formelement (e.g.as="div") when nesting one inside another form-like element.- Native HTML5 validation is always off. When rendered as a real
<form>, these components setnovalidate, matching formwerk's ownformProps. Without it the browser's constraint bubbles fire first and swallow thesubmitevent, so@submit/@errorwould never emit for a field markedrequired. There is nodisableHtmlValidationprop, because formwerk's own flag provably no-ops here (this module's fields never hand formwerk aninputEl). - Two forms sharing an explicit
:idshare event buses. Leaveidunset (it's auto-generated) unless you specifically want two form components to observe the same formwerk/Nuxt UI events.
USchemaForm and USchemalessForm own their useForm() call, which is what lets several forms live in one component. They expose the whole form API, so a useTemplateRef reaches values, setErrors, reset and the rest.
That ref is null until mount, though. When the form has to exist during setup — a watcher, a wizard registration, a composable that needs it synchronously — create it yourself and hand it to UFormRoot:
<script setup lang="ts">
import { useForm, type ConsumableData } from "@formwerk/core"
import { z } from "zod"
const schema = z.object({ email: z.string().email() })
const form = useForm({ schema })
// Available immediately — a template ref on the other roots would still be null here.
watch(
() => form.values.email,
(email) => console.log("draft", email),
)
const save = (data: ConsumableData<z.infer<typeof schema>>) => $fetch("/api/save", { method: "POST", body: data.toJSON() })
</script>
<template>
<UFormRoot :form="form" @submit="save">
<UFormField name="email" label="Email" #="{ model }">
<UInput v-bind="model" />
</UFormField>
</UFormRoot>
</template>Everything else behaves like the other roots: it renders a real <form> with novalidate, emits @submit and @error, and exposes the interaction sets.
| Prop | Type | Default | Description |
|---|---|---|---|
form |
FormReturns |
required | A form from useForm(). Read once at setup — use :key to swap it. |
as |
string | Component |
'form' |
Element to render as. Use a non-form element to avoid nested <form>. |
showErrorsOn |
'touched' | 'blur' | 'dirty' |
'blur' |
When field errors become visible. |
validateOnInputDelay |
number |
300 |
Debounce before Nuxt UI inputs emit input. |
There is deliberately no schema, id, initialValues, initialTouched, initialDirty or disabled. Those belong to your useForm() call, and accepting them here would mean silently ignoring them. disabled especially: formwerk's disabled context is created by useForm, so this component could only ever half-apply it.
The composable underneath all three roots, auto-imported. Reach for it when you want the wiring but not the element — useFormRoot(form, options) provides the buses and injection keys that UFormField, UFormGroup and UFormRepeater need, and returns { blurredFields, touchedFields, dirtyFields }. You then own the element, novalidate and submit handling. Options are all optional: showErrorsOn ('blur'), disabled (false), validateOnInputDelay (300).
Enhanced field component that wraps Nuxt UI's UFormField with formwerk validation.
<template>
<USchemalessForm>
<UFormField name="email" label="Email" required #="{ model }">
<UInput v-bind="model" type="email" />
</UFormField>
</USchemalessForm>
</template>Accepts all UFormField props except validateOnInputDelay, errorPattern, eagerValidation, and error (these are managed by formwerk).
error is absent because formwerk owns the message. For a server-side failure, write to the submit-error bag — form.context.setFieldSubmitErrors(path, message) — which displays regardless of showErrorsOn, since the field may never have been touched. form.setErrors writes to the same bag as schema validation instead, so on a pristine field it stays hidden until a submit is attempted.
model- Object containing{ modelValue, onUpdate:modelValue }for v-bind compatibilitysetValue- Function to update field valuevalue- Current field value (reactive)
Recommended usage: Use #="{ model }" and spread with v-bind="model" for compatibility with all Nuxt UI components.
Groups related form fields together for nested validation.
<template>
<USchemalessForm>
<UFormGroup name="address">
<UFormField name="street" label="Street" #="{ model }">
<UInput v-bind="model" />
</UFormField>
<UFormField name="city" label="City" #="{ model }">
<UInput v-bind="model" />
</UFormField>
</UFormGroup>
</USchemalessForm>
</template>| Prop | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Group identifier |
Dynamic array field component for managing lists of items with add, remove, and reorder capabilities.
<script setup lang="ts">
import { z } from "zod"
const schema = z.object({
contacts: z
.array(
z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email"),
}),
)
.min(1, "At least one contact required")
.max(5, "Maximum 5 contacts"),
})
</script>
<template>
<USchemaForm :schema="schema">
<UFormRepeater name="contacts" :min="1" :max="5" :ui="{ root: 'flex flex-col gap-3', item: 'p-4 border rounded-lg' }">
<template #default="{ index, items, isFirst, isLast, repeater }">
<div class="flex gap-4 items-end">
<UFormField name="name" label="Name" class="flex-1" #="{ model }">
<UInput v-bind="model" placeholder="Contact name" />
</UFormField>
<UFormField name="email" label="Email" class="flex-1" #="{ model }">
<UInput v-bind="model" placeholder="Contact email" />
</UFormField>
<div class="flex gap-1">
<UButton icon="i-lucide-arrow-up" variant="ghost" size="sm" :disabled="isFirst" @click="repeater.move(index, index - 1)" />
<UButton
icon="i-lucide-arrow-down"
variant="ghost"
size="sm"
:disabled="isLast"
@click="repeater.move(index, index + 1)" />
<UButton
icon="i-lucide-trash"
color="error"
variant="ghost"
size="sm"
:disabled="items.length <= 1"
@click="repeater.remove(index)" />
</div>
</div>
</template>
<template #trailing="{ items, repeater }">
<UButton icon="i-lucide-plus" variant="outline" :disabled="items.length >= 5" @click="repeater.add()"> Add Contact </UButton>
</template>
</UFormRepeater>
</USchemaForm>
</template>| Prop | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Field name for the array |
min |
number |
No | Minimum number of items |
max |
number |
No | Maximum number of items |
ui |
object |
No | Styling classes for slots |
{
root?: string // Class for the root container
leading?: string // Class for the leading slot wrapper
wrapper?: string // Class for the container around the items
item?: string // Class for each iteration item
trailing?: string // Class for the trailing slot wrapper
}default - Rendered for each item in the array
| Prop | Type | Description |
|---|---|---|
index |
number |
Current item index |
items |
readonly string[] |
Array of item keys |
isFirst |
boolean |
Whether this is the first item |
isLast |
boolean |
Whether this is the last item |
repeater |
RepeaterMethods |
Methods to manipulate the array |
leading - Content before the items (optional)
| Prop | Type | Description |
|---|---|---|
items |
readonly string[] |
Array of item keys |
repeater |
RepeaterMethods |
Methods to manipulate the array |
trailing - Content after the items (optional)
| Prop | Type | Description |
|---|---|---|
items |
readonly string[] |
Array of item keys |
repeater |
RepeaterMethods |
Methods to manipulate the array |
wrapper - Replaces the container around the items (optional). Receives the Iteration component so a custom container — a drag-and-drop list, say — can render the items itself without losing formwerk's per-item state.
| Prop | Type | Description |
|---|---|---|
items |
readonly string[] |
Array of item keys |
repeater |
RepeaterMethods |
Methods to manipulate the array |
Iteration |
Component |
Wraps one item; needs :index |
interface RepeaterMethods {
add: (count?: number) => void // Add items to the end
remove: (index: number) => void // Remove item at index
move: (from: number, to: number) => void // Move item from one index to another
swap: (indexA: number, indexB: number) => void // Swap two items
insert: (index: number, count?: number) => void // Insert items at index
}Note: Field names inside UFormRepeater should be relative (e.g.,
name,
<script setup lang="ts">
import { z } from "zod"
import type { ConsumableData } from "@formwerk/core"
const schema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
address: z.object({
street: z.string().min(1, "Street is required"),
city: z.string().min(1, "City is required"),
}),
})
const onSubmit = (data: ConsumableData<z.output<typeof schema>>) => {
console.log("Validated data:", data.toJSON())
}
</script>
<template>
<USchemaForm :schema="schema" show-errors-on="blur" @submit="onSubmit">
<div class="space-y-4">
<UFormField name="name" label="Name" required #="{ model }">
<UInput v-bind="model" />
</UFormField>
<UFormField name="email" label="Email" required #="{ model }">
<UInput v-bind="model" type="email" />
</UFormField>
<UFormField name="password" label="Password" required #="{ model }">
<UInput v-bind="model" type="password" />
</UFormField>
<UFormGroup name="address" class="space-y-4">
<UFormField name="street" label="Street" #="{ model }">
<UInput v-bind="model" />
</UFormField>
<UFormField name="city" label="City" #="{ model }">
<UInput v-bind="model" />
</UFormField>
</UFormGroup>
<UButton type="submit">Submit</UButton>
</div>
</USchemaForm>
</template>| Component | Description |
|---|---|
USchemaForm |
Self-contained, schema-driven form component |
USchemalessForm |
Self-contained, schema-free form component |
UFormRoot |
Root for a form you own; created via useForm() |
UFormField |
Field wrapper with validation (overrides Nuxt UI) |
UFormGroup |
Groups related fields for nested paths |
UFormRepeater |
Dynamic array fields with add/remove/reorder |
UForm is Nuxt UI's own component — this module does not touch it. Use it for forms that don't need formwerk.
Nuxt UI's original field is renamed and still accessible:
NuxtUiFormField- Original Nuxt UI FormField component
This module bridges @formwerk/core with @nuxt/ui by:
- USchemaForm / USchemalessForm create a formwerk form and manage dual event buses (one for Nuxt UI, one for formwerk)
- UFormField uses formwerk's
useCustomControlcomposable to register fields and handle validation - UFormRepeater uses formwerk's
useFormRepeatercomposable for array field management - Event coordination between both systems ensures validation triggers work as expected
- Field state (touched, blurred, dirty) is tracked and exposed to the parent form
The integration allows you to use Nuxt UI's beautiful form components while leveraging formwerk's powerful validation and state management capabilities.
Local development
# Install dependencies
pnpm install
# Generate type stubs and prepare playground
pnpm dev:prepare
# Develop with the playground
pnpm dev
# Build the playground
pnpm dev:build
# Run linter
pnpm lint
# Fix linting issues
pnpm lint:fix
# Format code
pnpm format
# Run tests
pnpm test
pnpm test:watch
# Type check
pnpm test:types
# Release new version
pnpm release