Guide

Forms

Typed HTML forms, nested field errors, root errors, and Nuxt UI integration.

Forms

useFormAction adds typed values, a submission snapshot, and initial-value reset to useAction. It has no UI-library dependency.

const form = useFormAction(updateProfile, {
  initialValues: { profile: { name: '', email: '' } },
})

values is a mutable ref inferred from action input. submit() snapshots current values and resolves the full result envelope. reset() cancels pending execution, clears action state, and restores the initial captured values. Use plain form objects/arrays/primitives. Dates can be cloned locally but JSON sends them as strings; use ISO strings in action contracts.

fieldErrors is always an object containing dotted paths. rootErrors combines legacy _root and transport _errors messages. All other useAction refs, methods, options, and callbacks remain available.

Working HTML form

This action returns a demonstration profile without persistent storage. The same runnable example is in the repository's example/ directory.

server/actions/update-profile.ts
import { z } from 'zod'
import { createSafeActionClient, returnValidationErrors } from '#safe-action'

export default createSafeActionClient()
  .schema(z.object({ profile: z.object({
    name: z.string().trim().min(2, 'Use at least two characters'),
    email: z.string().email('Enter a valid email'),
  }) }))
  .outputSchema(z.object({ profile: z.object({ name: z.string(), email: z.string() }) }))
  .action(({ parsedInput }) => {
    if (parsedInput.profile.name.toLowerCase() === 'admin') {
      returnValidationErrors({ 'profile.name': ['This name is reserved'] })
    }
    return { profile: parsedInput.profile }
  })
app/components/ProfileForm.vue
<script setup lang="ts">
import { updateProfile } from '#safe-action/actions'

const { values, submit, data, fieldErrors, rootErrors, serverError, isExecuting, reset } =
  useFormAction(updateProfile, { initialValues: { profile: { name: '', email: '' } } })
</script>

<template>
  <form @submit.prevent="submit()">
    <label for="profile-name">Name</label>
    <input id="profile-name" v-model="values.profile.name" :aria-invalid="Boolean(fieldErrors['profile.name'])" />
    <p v-for="message in fieldErrors['profile.name']" :key="message">{{ message }}</p>
    <label for="profile-email">Email</label>
    <input id="profile-email" v-model="values.profile.email" type="email" :aria-invalid="Boolean(fieldErrors['profile.email'])" />
    <p v-for="message in fieldErrors['profile.email']" :key="message">{{ message }}</p>
    <p v-for="message in rootErrors" :key="message" role="alert">{{ message }}</p>
    <p v-if="serverError" role="alert">{{ serverError }}</p>
    <button :disabled="isExecuting">Save</button>
    <button type="button" @click="reset()">Reset</button>
    <p v-if="data">Saved: {{ data.profile.name }}</p>
  </form>
</template>

For registration/business-rule validation, use the same pattern with server-side uniqueness checks and returnValidationErrors({ email: ['This email is already taken'] }). Enforce uniqueness in persistent storage as well, because concurrent requests can pass a preliminary lookup.

Nuxt UI UForm

Install/configure Nuxt UI separately. This recipe uses the action above and Nuxt UI's setErrors and dotted-path conventions. Client-side schema validation is optional; the server always validates.

<script setup lang="ts">
import type { FormSubmitEvent } from '@nuxt/ui'
import { updateProfile } from '#safe-action/actions'

const state = reactive({ profile: { name: '', email: '' } })
const form = useTemplateRef('form')
const rootErrors = ref<string[]>([])
const { executeAsync, isExecuting, serverError } = useAction(updateProfile)

async function onSubmit(event: FormSubmitEvent<typeof state>) {
  form.value?.clear()
  rootErrors.value = []
  const result = await executeAsync(event.data)
  const errors = result.validationErrors || {}
  rootErrors.value = [...(errors._root || []), ...(errors._errors || [])]
  form.value?.setErrors(Object.entries(errors)
    .filter(([name]) => name !== '_root' && name !== '_errors')
    .flatMap(([name, messages]) => messages.map(message => ({ name, message }))))
}
</script>

<template>
  <UForm ref="form" :state="state" @submit="onSubmit">
    <UFormField label="Name" name="profile.name">
      <UInput v-model="state.profile.name" />
    </UFormField>
    <UFormField label="Email" name="profile.email">
      <UInput v-model="state.profile.email" type="email" />
    </UFormField>
    <p v-for="message in rootErrors" :key="message" role="alert">{{ message }}</p>
    <p v-if="serverError" role="alert">{{ serverError }}</p>
    <UButton type="submit" :loading="isExecuting">Save</UButton>
  </UForm>
</template>

For arrays, names include indices such as addresses.0.city. Nuxt UI also supports error-pattern for collecting array-item messages. If a shared schema transforms input on the client, ensure you submit the action's input shape rather than accidentally applying a server transform twice.

Copyright © 2026