State & data layer

Forms and validation

One schema for client and server with Standard Schema libraries, Nuxt UI's UForm, VeeValidate and FormKit compared, field components with defineModel, async validation, accessible error display, and mapping server errors back to fields.

Forms are where state, validation, accessibility and the server meet, which makes them a favourite practical interview task ("build a signup form with validation"). The senior answer is a single schema shared by the client and the server route, a form library that renders errors accessibly, and a clear story for async and server-side errors.

Know

  • One schema, two sides. Define the schema in shared/schemas/ with zod (v4), valibot or ArkType, all of which implement the Standard Schema spec that Nuxt UI, VeeValidate and h3's readValidatedBody accept. The client validates for feedback; the server validates for truth. Types come from the schema (z.infer), never written twice.
  • Nuxt UI UForm takes :schema and :state, validates on input/change/blur/submit (validate-on), maps issues to UFormField by name (dot paths for nested state), supports a custom validate function for async rules, nested forms attached to a parent, and emits @submit with event.data typed from the schema and @error with the field errors. form.validate(), form.clear(), form.setErrors() cover programmatic control.
  • Alternatives: VeeValidate (useForm + useField, toTypedSchema(zodSchema), handleSubmit, field-level meta for touched/dirty) when you are not on Nuxt UI; FormKit for schema-driven, generated forms; plain reactive state plus schema.safeParse for tiny forms.
  • Field components are defineModel-based and accessible by construction: useId for label/for, aria-invalid and aria-describedby pointing at the error, aria-required, error text in a live region when it appears after submit.
  • Validation timing: validate on blur for first entry, on input once a field has been touched, and always on submit; debounce expensive rules; async rules (username availability) run on blur or debounced input with cancellation and never block typing.
  • Server errors map back to fields. The route throws createError({ status: 400, statusText: 'Validation failed', data: { issues } }) (which readValidatedBody does automatically); the client reads error.data.issues from the FetchError and calls form.setErrors(); anything not tied to a field goes to a form-level alert.
  • Submission hygiene: disable the submit button while pending (or guard with a flag), keep values on failure, reset on success only when appropriate, and use onBeforeRouteLeave for unsaved-changes prompts. Files go through FormData (readFormData/readMultipartFormData on the server), not JSON.
  • Accessibility of forms is a WCAG-level requirement, not polish: labels for every control, error messages associated with their field, a summary or focus move on submit failure, no placeholder-as-label. The CSS and accessibility section has the criteria.

How it works

The schema, shared:

shared/schemas/signup.ts
import { z } from 'zod'

export const signupSchema = z.object({
  email: z.string().email('Enter a valid email'),
  password: z.string().min(12, 'At least 12 characters'),
  displayName: z.string().trim().min(2).max(40),
  marketing: z.boolean().default(false),
})
export type SignupInput = z.input<typeof signupSchema>
export type Signup = z.output<typeof signupSchema>

The server route, validating for real and returning field-level issues:

server/api/signup.post.ts
import { signupSchema } from '#shared/schemas/signup'

export default defineEventHandler(async (event) => {
  const body = await readValidatedBody(event, signupSchema.parse) // 400 with issues in `data` on failure
  if (await useDb().users.exists(body.email)) {
    throw createError({
      status: 409,
      statusText: 'Email already registered',
      data: { issues: [{ path: ['email'], message: 'This email is already registered' }] },
    })
  }
  return useDb().users.create(body)
})

The form with Nuxt UI, async availability check, and server errors mapped back:

app/pages/signup.vue
<script setup lang="ts">
import type { FormSubmitEvent, FormError } from '@nuxt/ui'
import type { FetchError } from 'ofetch'
import { signupSchema, type Signup, type SignupInput } from '#shared/schemas/signup'

const form = useTemplateRef('form')
const state = reactive<SignupInput>({ email: '', password: '', displayName: '', marketing: false })
const pending = ref(false)
const formError = ref<string | null>(null)

// Async rule: runs with the schema validation; cheap enough here, debounce for anything heavier.
async function validate(s: SignupInput): Promise<FormError[]> {
  if (!s.displayName) return []
  const { taken } = await $fetch<{ taken: boolean }>('/api/signup/check-name', { query: { name: s.displayName } })
  return taken ? [{ name: 'displayName', message: 'That name is taken' }] : []
}

async function onSubmit(event: FormSubmitEvent<Signup>) {
  pending.value = true
  formError.value = null
  try {
    await $fetch('/api/signup', { method: 'POST', body: event.data })
    await navigateTo('/welcome')
  }
  catch (e) {
    const err = e as FetchError<{ issues?: { path: (string | number)[], message: string }[] }>
    const issues = err.data?.issues
    if (issues?.length) form.value?.setErrors(issues.map(i => ({ name: i.path.join('.'), message: i.message })))
    else formError.value = err.data?.statusText ?? 'Something went wrong. Please try again.'
  }
  finally {
    pending.value = false
  }
}
</script>

<template>
  <UForm ref="form" :schema="signupSchema" :state="state" :validate="validate" validate-on="blur" class="space-y-4" @submit="onSubmit">
    <UAlert v-if="formError" color="error" :title="formError" role="alert" />
    <UFormField label="Email" name="email" required>
      <UInput v-model="state.email" type="email" autocomplete="email" />
    </UFormField>
    <UFormField label="Password" name="password" required hint="At least 12 characters">
      <UInput v-model="state.password" type="password" autocomplete="new-password" />
    </UFormField>
    <UFormField label="Display name" name="displayName" required>
      <UInput v-model="state.displayName" autocomplete="nickname" />
    </UFormField>
    <UCheckbox v-model="state.marketing" name="marketing" label="Send me product news" />
    <UButton type="submit" :loading="pending" :disabled="pending">Create account</UButton>
  </UForm>
</template>

An accessible custom field for teams not on a UI library:

app/components/Form/TextField.vue
<script setup lang="ts">
const { label, error, hint } = defineProps<{ label: string, error?: string, hint?: string }>()
const model = defineModel<string>({ default: '' })
const id = useId()
</script>

<template>
  <div>
    <label :for="id">{{ label }}</label>
    <input
      :id="id"
      v-model="model"
      :aria-invalid="!!error || undefined"
      :aria-describedby="[hint && `${id}-hint`, error && `${id}-error`].filter(Boolean).join(' ') || undefined"
    >
    <p v-if="hint" :id="`${id}-hint`">{{ hint }}</p>
    <p v-if="error" :id="`${id}-error`" role="alert">{{ error }}</p>
  </div>
</template>
Gotcha· Validating only on the client

Client validation is a convenience; the API is reachable without the form. Every route that accepts input parses it with the same schema (readValidatedBody), and the client treats the server's issues as authoritative by mapping them back onto the fields.

Gotcha· Two schemas drifting apart

A zod schema in the component and a differently written one in the route will disagree within a month. Import one schema from shared/ on both sides; if the server needs extra rules (uniqueness), it extends the shared schema rather than redefining it.

Verify before the interview:

Nuxt UI 4's UForm props (validate-on, attach/nested forms), the setErrors signature, and which Standard Schema libraries are listed as supported.

docs ↗

Exercise

Exercise
  • Build the signup form with the shared schema; submit invalid JSON directly to the route with curl and confirm the 400 with issues; then submit a duplicate email and watch it land on the field.
  • Turn off client validation and test the form with a screen reader: are errors announced, is focus managed?
  • Add an unsaved-changes guard with onBeforeRouteLeave and confirm it does not fire after a successful submit.

Be able to say

Be able to say· How do you build forms in a Nuxt application?

"One schema shared by client and server, written with zod or valibot in shared/schemas, so the types and the rules exist once. On the client I use Nuxt UI's UForm with that schema and typed submit data, validating on blur first and on submit always, with async rules such as availability checks debounced and cancellable. The server route validates with readValidatedBody using the same schema, and when it rejects, the field-level issues in the error's data are mapped back onto the form with setErrors, anything else to a form-level alert. Fields are accessible by construction: labels tied by useId, aria-invalid, aria-describedby for hints and errors, and errors announced when they appear."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.