21

Server errors

Submit to an API and show field-level and form-level errors that the server returns.

Client-side validation cannot know everything. A server may reject an email that is already registered, or fail for reasons unrelated to any field. This recipe turns those responses into errors your form displays.

The flow

  1. Submit. The schema validates first, so onSubmit only runs with valid data.
  2. Send the data. Inspect the response.
  3. For field problems, call form.setError({ message, path }).
  4. For everything else, call form.setError({ message }), which has no path and is a form-level error.

Example

<script setup lang="ts">
import { Field, FieldError, Form, useForm } from 'notform'
import { computed } from 'vue'
import { z } from 'zod'

const form = useForm({
  initialValues: { email: '', username: '' },
  async onSubmit(data) {
    try {
      const response = await fetch('/api/register', {
        body: JSON.stringify(data),
        headers: { 'content-type': 'application/json' },
        method: 'POST',
      })

      if (response.status === 422) {
        // { errors: { email: 'Already in use.', username: '...' } }
        const body = await response.json() as { errors: Record<string, string> }

        for (const [name, message] of Object.entries(body.errors)) {
          form.setError({ message, path: [name] })
        }

        return
      }

      if (!response.ok) {
        form.setError({ message: 'Something went wrong. Please try again.' })
        return
      }

      form.reset()
    } catch {
      form.setError({ message: 'We could not reach the server. Check your connection.' })
    }
  },
  schema: z.object({
    email: z.email('Enter a valid email address.'),
    username: z.string().min(3, 'Use at least 3 characters.'),
  }),
})

const formErrors = computed(() => {
  return form.errors.filter((issue) => {
    return !issue.path?.length
  })
})
</script>

<template>
  <Form
    :form="form"
    novalidate
  >
    <FieldError
      :errors="formErrors"
      as="p"
      role="alert"
    />

    <Field
      v-slot="{ field }"
      :field="form.field('username')"
    >
      <label :for="field.path">Username</label>

      <input
        :id="field.path"
        v-model="field.model"
        v-bind="field.events"
      >

      <FieldError :errors="field" />
    </Field>

    <Field
      v-slot="{ field }"
      :field="form.field('email')"
    >
      <label :for="field.path">Email</label>

      <input
        :id="field.path"
        v-model="field.model"
        v-bind="field.events"
        type="email"
      >

      <FieldError :errors="field" />
    </Field>

    <button
      type="submit"
      :disabled="form.isSubmitting"
    >
      Create account
    </button>
  </Form>
</template>

How long server errors last

ErrorCleared 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.

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.
  • isSubmitting stays true until onSubmit settles, so the button stays disabled during the request.
Copyright © 2026 NotForm
Published under MIT License