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.
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.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.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.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.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.onBeforeRouteLeave for unsaved-changes prompts. Files go through FormData (readFormData/readMultipartFormData on the server), not JSON.The schema, shared:
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:
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:
<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:
<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>
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.
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.
Nuxt UI 4's UForm props (validate-on, attach/nested forms), the setErrors signature, and which Standard Schema libraries are listed as supported.
curl and confirm the 400 with issues; then submit a duplicate email and watch it land on the field.onBeforeRouteLeave and confirm it does not fire after a successful submit."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."
Nothing yet. Notes filed here show up on this page and the section overview.
Server state and caching
Treating API data as a cache rather than state — stale-while-revalidate, keys and invalidation, optimistic updates with rollback, pagination and infinite queries — with useAsyncData, TanStack Query and Pinia Colada compared.
Realtime and sync
WebSockets versus Server-Sent Events versus polling, Nitro's WebSocket and event-stream support, reconnection and resumption, keeping clients in sync with optimistic local state, and scaling realtime beyond one server.