---
title: "Server errors"
description: "Submit to an API and show field-level and form-level errors that the server returns."
canonical_url: "https://notformdocs.vercel.app/docs/recipes/server-errors"
---

> Index of every page on this site: [llms.txt](https://notformdocs.vercel.app/llms.txt)

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

<code-collapse>

```vue
<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>
```

</code-collapse>

## How long server errors last

<table>
<thead>
  <tr>
    <th>
      Error
    </th>
    
    <th>
      Cleared when
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      A field error from <code>
        setError({ path })
      </code>
    </td>
    
    <td>
      That field validates again and the schema reports no issue there, for example on blur or when the user edits it. Also on <code>
        reset()
      </code>
      
      , and on the next submit.
    </td>
  </tr>
  
  <tr>
    <td>
      A form-level error from <code>
        setError({ message })
      </code>
    </td>
    
    <td>
      The next submit, a <code>
        form.validate()
      </code>
      
      , or <code>
        reset()
      </code>
      
      .
    </td>
  </tr>
</tbody>
</table>

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.

```ts
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](https://notformdocs.vercel.app/docs/guides/submission-and-reset#errors-thrown-in-onsubmit).
- `isSubmitting` stays `true` until `onSubmit` settles, so the button stays disabled during the request.

## Sitemap

See the full [sitemap](https://notformdocs.vercel.app/sitemap.md) for all pages.
