21

Nuxt UI

Use NotForm with Nuxt UI inputs and form fields, including text, textarea, select, checkbox, checkbox group, radio group, switch, and array fields.

Nuxt UI provides styled inputs and a UFormField wrapper for labels and error text. NotForm owns the form state and validation, and Nuxt UI renders the controls.

Setup

Install both modules and register them.

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/ui', '@notform/nuxt'],
})

Use NotForm's form, not UForm

Wrap your controls in NotForm's Form, not Nuxt UI's UForm. UForm has its own validation and submit pipeline. Using both makes two systems compete over the same state.

UFormField is safe to use on its own. It lays out the label, description, and error text.

Choose the right binding

Bind the model with v-model:model-value, then report changes and blur to NotForm yourself. Text inputs report with onInput, and the other controls report with onChange.

ComponentBinding
UInput, UTextareav-model="field.model", @update:model-value="field.events.onInput" and @blur="field.events.onBlur"
USelect, UCheckbox, UCheckboxGroup, URadioGroup, USwitchv-model="field.model", @update:model-value="field.events.onChange"and @blur="field.events.onBlur"

Show errors

UFormField renders error text from its error prop, so you do not need FieldError here. Pass it the first message of field.errors with a small helper:

import type { ValidationIssue } from 'notform'

function getFirstErrorMessage(errors?: ValidationIssue[]) {
  return errors?.[0]?.message
}

Each section below shows the schema and the template it binds to. The snippets assume the form, getFirstErrorMessage and the items from the full example.

Text input

title: z.string().min(5, 'Bug title must be at least 5 characters.')
  .max(32)
<template>
  <Field
    v-slot="{ field }"
    :field="form.field('title')"
  >
    <UFormField
      label="Bug Title"
      :error="getFirstErrorMessage(field.errors)"
    >
      <UInput
        v-model="field.model"
        placeholder="Login button not working on mobile"
        autocomplete="off"
        class="inline-full"
        @update:model-value="field.events.onInput"
        @blur="field.events.onBlur"
      />
    </UFormField>
  </Field>
</template>

Textarea

UTextarea takes the same bindings as a text input. Because field.model is reactive, you can read it anywhere in the slot, which is how the character counter stays in sync.

description: z.string().min(20, 'Description must be at least 20 characters.')
  .max(100)
<template>
  <Field
    v-slot="{ field }"
    :field="form.field('description')"
  >
    <UFormField
      label="Description"
      help="Include steps to reproduce, expected behavior, and what actually happened."
      :error="getFirstErrorMessage(field.errors)"
    >
      <UTextarea
        v-model="field.model"
        :rows="6"
        class="inline-full"
        placeholder="I'm having an issue with the login button on mobile."
        @update:model-value="field.events.onInput"
        @blur="field.events.onBlur"
      />

      <p class="mbs-1 text-xs text-muted tabular-nums">
        {{ field.model?.length || 0 }}/100 characters
      </p>
    </UFormField>
  </Field>
</template>

Select

Pass the options through items.

language: z.string().min(1, 'Please select your spoken language.')
<template>
  <Field
    v-slot="{ field }"
    :field="form.field('language')"
  >
    <UFormField
      label="Spoken Language"
      description="For best results, select the language you speak."
      :error="getFirstErrorMessage(field.errors)"
    >
      <USelect
        v-model="field.model"
        :items="spokenLanguages"
        placeholder="Select"
        class="inline-full"
        @update:model-value="field.events.onChange"
        @blur="field.events.onBlur"
      />
    </UFormField>
  </Field>
</template>

Checkbox

responses: z.literal(true, 'Notifications are mandatory')
<template>
  <Field
    v-slot="{ field }"
    :field="form.field('responses')"
  >
    <UFormField
      label="Responses"
      description="Get notified for requests that take time."
      :error="getFirstErrorMessage(field.errors)"
    >
      <UCheckbox
        v-model="field.model"
        label="Push notifications"
        @update:model-value="field.events.onChange"
        @blur="field.events.onBlur"
      />
    </UFormField>
  </Field>
</template>

Checkbox group

UCheckboxGroup binds the array of selected values directly through v-model:model-value.

tasks: z.array(z.string()).min(1, 'Please select at least one notification type.')
<template>
  <Field
    v-slot="{ field }"
    :field="form.field('tasks')"
  >
    <UFormField :error="getFirstErrorMessage(field.errors)">
      <UCheckboxGroup
        v-model="field.model"
        legend="Tasks"
        :items="tasks"
        @update:model-value="field.events.onChange"
        @blur="field.events.onBlur"
      />
    </UFormField>
  </Field>
</template>

Radio group

plan: z.string().min(1, 'You must select a subscription plan to continue.')
<template>
  <Field
    v-slot="{ field }"
    :field="form.field('plan')"
  >
    <UFormField
      description="You can upgrade or downgrade your plan at any time."
      :error="getFirstErrorMessage(field.errors)"
    >
      <URadioGroup
        v-model="field.model"
        legend="Plan"
        variant="card"
        indicator="end"
        :items="plans"
        @update:model-value="field.events.onChange"
        @blur="field.events.onBlur"
      />
    </UFormField>
  </Field>
</template>

Switch

twoFactor: z.boolean().refine((value) => {
  return value === true
}, 'It is highly recommended to enable two-factor authentication.')
<template>
  <Field
    v-slot="{ field }"
    :field="form.field('twoFactor')"
  >
    <UFormField
      label="Multi-factor authentication"
      description="Enable multi-factor authentication to secure your account."
      :error="getFirstErrorMessage(field.errors)"
    >
      <USwitch
        v-model="field.model"
        @update:model-value="field.events.onChange"
        @blur="field.events.onBlur"
      />
    </UFormField>
  </Field>
</template>

Array fields

Use ArrayField for lists the user can grow, shrink, and reorder. Each item exposes item.field('key'), so you can wrap any of the inputs above in a Field.

emails: z.array(z.object({ address: z.email('Enter a valid email address.') }))
  .min(2, 'Add at least two email addresses.')
  .max(5, 'You can add up to 5 email addresses.')
<template>
  <ArrayField
    v-slot="{ arrayField }"
    :array-field="form.arrayField('emails')"
  >
    <UFormField
      label="Email Addresses"
      description="Add up to 5 email addresses where we can contact you."
      :error="getFirstErrorMessage(arrayField.errors)"
    >
      <div class="space-y-3">
        <Field
          v-for="item in arrayField.items"
          :key="item.id"
          v-slot="{ field }"
          :field="item.field('address')"
        >
          <UFormField :error="getFirstErrorMessage(field.errors)">
            <UInput
              v-model="field.model"
              type="email"
              placeholder="name@example.com"
              class="inline-full"
              @update:model-value="field.events.onInput"
              @blur="field.events.onBlur"
            >
              <template
                v-if="arrayField.items.length > 1"
                #trailing
              >
                <UButton
                  icon="i-lucide-chevron-up"
                  color="neutral"
                  variant="ghost"
                  size="xs"
                  :disabled="item.index === 0"
                  @click="arrayField.move(item.index, item.index - 1)"
                />

                <UButton
                  icon="i-lucide-chevron-down"
                  color="neutral"
                  variant="ghost"
                  size="xs"
                  :disabled="item.index === arrayField.items.length - 1"
                  @click="arrayField.move(item.index, item.index + 1)"
                />

                <UButton
                  icon="i-lucide-x"
                  color="neutral"
                  variant="ghost"
                  size="xs"
                  @click="arrayField.remove(item.index)"
                />
              </template>
            </UInput>
          </UFormField>
        </Field>

        <UButton
          color="neutral"
          variant="outline"
          block
          size="sm"
          :disabled="arrayField.items.length >= 5"
          @click="arrayField.append({ address: '' })"
        >
          Add Email Address
        </UButton>
      </div>
    </UFormField>
  </ArrayField>
</template>

A few things to note:

  • arrayField.items gives you each item's id and index. Use item.id as the :key, not the index, so inputs keep their state when you reorder or remove items.
  • arrayField.append, arrayField.remove and arrayField.move change the list.
  • Errors for a single item come from its field. Errors for the list as a whole, such as the min and max rules, come from arrayField.errors.
  • Mirror the schema limits in the UI, as the Add button does with arrayField.items.length >= 5, so users can't reach an invalid state by clicking.

Full example

This form combines every input above.

<script setup lang="ts">
import type { ValidationIssue } from 'notform'
import { z } from 'zod'

const spokenLanguages = [
  { label: 'Auto', value: 'auto' },
  { type: 'separator' as const },
  { label: 'English', value: 'en' },
  { label: 'Spanish', value: 'es' },
  { label: 'French', value: 'fr' },
]

const plans = [
  { description: 'For everyday use with basic features.', label: 'Starter (100K tokens/month)', value: 'starter' },
  { description: 'For advanced AI usage with more features.', label: 'Pro (1M tokens/month)', value: 'pro' },
  { description: 'For large teams and heavy usage.', label: 'Enterprise (Unlimited tokens)', value: 'enterprise' },
]

const tasks = [
  { label: 'Push notifications', value: 'push' },
  { label: 'Email notifications', value: 'email' },
]

const schema = z.object({
  description: z.string().min(20, 'Description must be at least 20 characters.')
    .max(100),
  emails: z.array(z.object({ address: z.email('Enter a valid email address.') }))
    .min(2, 'Add at least two email addresses.')
    .max(5, 'You can add up to 5 email addresses.'),
  language: z.string().min(1, 'Please select your spoken language.')
    .refine((value) => {
      return value !== 'auto'
    }, 'Auto-detection is not allowed.'),
  plan: z.string().min(1, 'You must select a subscription plan to continue.'),
  responses: z.literal(true, 'Notifications are mandatory'),
  tasks: z.array(z.string()).min(1, 'Please select at least one notification type.'),
  title: z.string().min(5, 'Bug title must be at least 5 characters.')
    .max(32),
  twoFactor: z.boolean().refine((value) => {
    return value === true
  }, 'It is highly recommended to enable two-factor authentication.'),
})

const form = useForm({
  initialValues: {
    description: '',
    emails: [{ address: '' }, { address: '' }],
    language: '',
    plan: '',
    responses: true,
    tasks: [],
    title: '',
    twoFactor: false,
  },
  onSubmit(data) {
    console.log(data)
  },
  schema,
})

function getFirstErrorMessage(errors?: ValidationIssue[]) {
  return errors?.[0]?.message
}
</script>

<template>
  <UApp>
    <Form
      :form
      class="mx-auto space-y-6 p-8 inline-full max-inline-xl"
    >
      <!-- Text input -->
      <Field
        v-slot="{ field }"
        :field="form.field('title')"
      >
        <UFormField
          label="Bug Title"
          :error="getFirstErrorMessage(field.errors)"
        >
          <UInput
            v-model="field.model"
            placeholder="Login button not working on mobile"
            autocomplete="off"
            class="inline-full"
            @update:model-value="field.events.onInput"
            @blur="field.events.onBlur"
          />
        </UFormField>
      </Field>

      <!-- Textarea -->
      <Field
        v-slot="{ field }"
        :field="form.field('description')"
      >
        <UFormField
          label="Description"
          help="Include steps to reproduce, expected behavior, and what actually happened."
          :error="getFirstErrorMessage(field.errors)"
        >
          <UTextarea
            v-model="field.model"
            :rows="6"
            class="inline-full"
            placeholder="I'm having an issue with the login button on mobile."
            @update:model-value="field.events.onInput"
            @blur="field.events.onBlur"
          />

          <p class="mbs-1 text-xs text-muted tabular-nums">
            {{ field.model?.length || 0 }}/100 characters
          </p>
        </UFormField>
      </Field>

      <!-- Select -->
      <Field
        v-slot="{ field }"
        :field="form.field('language')"
      >
        <UFormField
          label="Spoken Language"
          description="For best results, select the language you speak."
          :error="getFirstErrorMessage(field.errors)"
        >
          <USelect
            v-model="field.model"
            :items="spokenLanguages"
            placeholder="Select"
            class="inline-full"
            @update:model-value="field.events.onChange"
            @blur="field.events.onBlur"
          />
        </UFormField>
      </Field>

      <!-- Checkbox -->
      <Field
        v-slot="{ field }"
        :field="form.field('responses')"
      >
        <UFormField
          label="Responses"
          description="Get notified for requests that take time."
          :error="getFirstErrorMessage(field.errors)"
        >
          <UCheckbox
            v-model="field.model"
            label="Push notifications"
            @update:model-value="field.events.onChange"
            @blur="field.events.onBlur"
          />
        </UFormField>
      </Field>

      <!-- Checkbox group -->
      <Field
        v-slot="{ field }"
        :field="form.field('tasks')"
      >
        <UFormField :error="getFirstErrorMessage(field.errors)">
          <UCheckboxGroup
            v-model="field.model"
            legend="Tasks"
            :items="tasks"
            @update:model-value="field.events.onChange"
            @blur="field.events.onBlur"
          />
        </UFormField>
      </Field>

      <!-- Radio group -->
      <Field
        v-slot="{ field }"
        :field="form.field('plan')"
      >
        <UFormField
          description="You can upgrade or downgrade your plan at any time."
          :error="getFirstErrorMessage(field.errors)"
        >
          <URadioGroup
            v-model="field.model"
            legend="Plan"
            variant="card"
            indicator="end"
            :items="plans"
            @update:model-value="field.events.onChange"
            @blur="field.events.onBlur"
          />
        </UFormField>
      </Field>

      <!-- Switch -->
      <Field
        v-slot="{ field }"
        :field="form.field('twoFactor')"
      >
        <UFormField
          label="Multi-factor authentication"
          description="Enable multi-factor authentication to secure your account."
          :error="getFirstErrorMessage(field.errors)"
        >
          <USwitch
            v-model="field.model"
            @update:model-value="field.events.onChange"
            @blur="field.events.onBlur"
          />
        </UFormField>
      </Field>

      <!-- Array field -->
      <ArrayField
        v-slot="{ arrayField }"
        :array-field="form.arrayField('emails')"
      >
        <UFormField
          label="Email Addresses"
          description="Add up to 5 email addresses where we can contact you."
          :error="getFirstErrorMessage(arrayField.errors)"
        >
          <div class="space-y-3">
            <Field
              v-for="item in arrayField.items"
              :key="item.id"
              v-slot="{ field }"
              :field="item.field('address')"
            >
              <UFormField :error="getFirstErrorMessage(field.errors)">
                <UInput
                  v-model="field.model"
                  type="email"
                  placeholder="name@example.com"
                  class="inline-full"
                  @update:model-value="field.events.onInput"
                  @blur="field.events.onBlur"
                >
                  <template
                    v-if="arrayField.items.length > 1"
                    #trailing
                  >
                    <UButton
                      icon="i-lucide-chevron-up"
                      color="neutral"
                      variant="ghost"
                      size="xs"
                      :disabled="item.index === 0"
                      @click="arrayField.move(item.index, item.index - 1)"
                    />

                    <UButton
                      icon="i-lucide-chevron-down"
                      color="neutral"
                      variant="ghost"
                      size="xs"
                      :disabled="item.index === arrayField.items.length - 1"
                      @click="arrayField.move(item.index, item.index + 1)"
                    />

                    <UButton
                      icon="i-lucide-x"
                      color="neutral"
                      variant="ghost"
                      size="xs"
                      @click="arrayField.remove(item.index)"
                    />
                  </template>
                </UInput>
              </UFormField>
            </Field>

            <UButton
              color="neutral"
              variant="outline"
              block
              size="sm"
              :disabled="arrayField.items.length >= 5"
              @click="arrayField.append({ address: '' })"
            >
              Add Email Address
            </UButton>
          </div>
        </UFormField>
      </ArrayField>

      <div
        class="
          flex gap-2

          *:flex-1 *:justify-center
        "
      >
        <UButton
          type="reset"
          color="neutral"
          variant="outline"
        >
          Reset
        </UButton>

        <UButton type="submit">
          Submit
        </UButton>
      </div>
    </Form>
  </UApp>
</template>
Copyright © 2026 NotForm
Published under MIT License