---
title: "Server validation"
description: "Validate request bodies in Nitro routes with the schema your form uses."
canonical_url: "https://nuxt-formisch.onmax.me/forms/server-validation"
---
# Server validation

> Validate request bodies in Nitro routes with the schema your form uses.

Client validation is for the user. The server still has to check the body itself. `readValidatedFormBody` is auto-imported in `server/` and does that with the same schema.

```ts [shared/utils/schemas.ts]
import * as v from 'valibot'

export const ProfileSchema = v.object({
  name: v.pipe(v.string(), v.minLength(2)),
  email: v.pipe(v.string(), v.email('Enter a valid email')),
})
```

```ts [server/api/profile.post.ts]
export default defineEventHandler(async (event) => {
  // typed as v.InferOutput<typeof ProfileSchema>
  const profile = await readValidatedFormBody(event, ProfileSchema)
  return { saved: profile }
})
```

Schemas in `shared/utils/` are auto-imported in `app/` and `server/`, so neither file needs an import.

## Signature

```ts
function readValidatedFormBody<TSchema extends StandardSchemaV1>(
  event: H3Event,
  schema: TSchema,
): Promise<StandardSchemaV1.InferOutput<TSchema>>
```

It reads JSON and URL-encoded bodies and returns the schema's output, with transforms applied. Any [Standard Schema](https://standardschema.dev) works, so a Zod or ArkType schema is accepted here too, though forms themselves need Valibot.

## Errors

An invalid body throws a `400 Validation Error`. Its `data.issues` lists every problem, with the field path as plain keys and indexes:

```json
{
  "statusCode": 400,
  "statusMessage": "Validation Error",
  "message": "Enter a valid email",
  "data": {
    "issues": [{ "message": "Enter a valid email", "path": ["email"] }]
  }
}
```

The types are exported as `FormIssue` and `FormValidationErrorData` from `nuxt-formisch`.

## Show server errors on the fields

The server often knows more than the client, like which emails are taken. Give the server its own rule on top of the shared schema:

```ts [server/api/profile.post.ts]
import * as v from 'valibot'

const ServerSchema = v.object({
  ...ProfileSchema.entries,
  email: v.pipe(
    ProfileSchema.entries.email,
    v.check(email => email !== 'taken@example.com', 'This email is already registered'),
  ),
})

export default defineEventHandler(async (event) => {
  return { saved: await readValidatedFormBody(event, ServerSchema) }
})
```

Then put the issues back on the matching fields with `setErrors`. `$fetch` puts the response body in `error.data`, so the issues are at `error.data.data.issues`:

```vue [app/pages/profile.vue]
<script setup lang="ts">
import * as v from 'valibot'

const form = useForm({ schema: ProfileSchema })

const ValidationError = v.object({
  data: v.object({
    data: v.object({
      issues: v.array(v.object({ message: v.string(), path: v.array(v.keyof(ProfileSchema)) })),
    }),
  }),
})

const onSubmit: SubmitHandler<typeof ProfileSchema> = async (profile) => {
  try {
    await $fetch('/api/profile', { method: 'POST', body: profile })
  }
  catch (error) {
    const result = v.safeParse(ValidationError, error)
    if (!result.success)
      throw error // lands in form.errors
    for (const { path: [key], message } of result.output.data.data.issues) {
      if (key)
        setErrors(form, { path: [key], errors: [message] })
    }
  }
}
</script>
```

Errors the handler throws end up in `form.errors`, so show that too: `<p v-if="form.errors">{{ form.errors[0] }}</p>`.


## Sitemap

See the full [sitemap](https://nuxt-formisch.onmax.me/sitemap.md) for all pages.
