---
title: "First form"
description: "A login form with validation, errors and a loading state."
canonical_url: "https://nuxt-formisch.onmax.me/getting-started/first-form"
---
# First form

> A login form with validation, errors and a loading state.

## Define a schema

Formisch reads types and validation rules from a Valibot schema. Put it in `shared/utils/` so pages and server routes can both use it; Nuxt auto-imports it on both sides.

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

export const LoginSchema = v.object({
  email: v.pipe(v.string(), v.email('Enter a valid email.')),
  password: v.pipe(v.string(), v.minLength(8, 'Use at least 8 characters.')),
})
```

## Create the form

`useForm` creates the form store. `<FForm>` wraps a native `<form>`, validates on submit, and calls your handler with typed values only when they are valid. `<FField>` connects an input to a path in the schema.

```vue [app/pages/login.vue]
<script setup lang="ts">
const form = useForm({ schema: LoginSchema })

const onSubmit: SubmitHandler<typeof LoginSchema> = async (values) => {
  await $fetch('/api/login', { method: 'POST', body: values })
}
</script>

<template>
  <FForm :of="form" @submit="onSubmit">
    <FField v-slot="field" :of="form" :path="['email']">
      <input v-model="field.input" v-bind="field.props" type="email">
      <p v-if="field.errors">{{ field.errors[0] }}</p>
    </FField>

    <FField v-slot="field" :of="form" :path="['password']">
      <input v-model="field.input" v-bind="field.props" type="password">
      <p v-if="field.errors">{{ field.errors[0] }}</p>
    </FField>

    <button type="submit" :disabled="form.isSubmitting">
      {{ form.isSubmitting ? 'Logging in…' : 'Log in' }}
    </button>
  </FForm>
</template>
```

`field.props` carries the `name`, focus and blur handlers Formisch needs to track touched state and move focus to the first invalid field. Bind it to the native input.

## Choose when to validate

By default Formisch validates on submit, then on every input after the first submit. Change both with `validate` and `revalidate`:

```ts
const form = useForm({
  schema: LoginSchema,
  initialInput: { email: 'ada@example.com' },
  validate: 'blur',
  revalidate: 'input',
})
```

Modes are `'initial'`, `'touch'`, `'input'`, `'change'`, `'blur'` and `'submit'`.

## Validate on the server

Validate the same body again in the API route. `readValidatedFormBody` throws a 400 error with the issues if it doesn't match:

```ts [server/api/login.post.ts]
export default defineEventHandler(async (event) => {
  const { email } = await readValidatedFormBody(event, LoginSchema)
  return { email }
})
```

## Next

- [Composables](https://nuxt-formisch.onmax.me/forms/composables) for `useField` and field arrays in your own input components.
- [Methods](https://nuxt-formisch.onmax.me/forms/methods) to read, set and reset values from code.
- [AutoForm](https://nuxt-formisch.onmax.me/auto-form) to render this whole form from the schema with Nuxt UI.


## Sitemap

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