example

Base usage

  1. First step is to define you errors parsers. Usually you can do in utils or shared/utils directory.
import type { ValidationErrorParser } from 'nuxt-precognition'

// app/utils/precognition.ts or shared/utils/precognition.ts
export const customErrorParser: ValidationErrorParser = (error) => {
  if (error instanceof CustomError) {
    const errors: Record<string, string[]> = {}
    for (const issue of error.issues) {
      const key = issue.path.join('.')
      if (key in errors) {
        errors[key].push(issue.message)
        continue
      }
      errors[key] = [issue.message]
    }
    return { errors, message: error.message }
  }
  return null
}
  1. Add the parsers globally
// app/plugins/precognition.ts
export default defineNuxtPlugin(() => {
  const { $precognition } = useNuxtApp()

  $precognition.errorParsers.push(customErrorParser)

  // ..
})

Thats it!

From now on, the module knows how to parse ValidationErrors.

Note: Remember that global parsers will be used all times when Error is intercepted.

Example usage

Use the composable in setup method.

const UserSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
})

const form = useForm(
  (): z.infer<typeof UserSchema> => ({
    email: '',
    password: '',
  }),
  (body, headers) => $fetch('/api/login', {
    method: 'POST',
    headers,
    body,
  }),
  {
    clientValidation(data) {
      UserSchema.parse(data)
    },
  },
)

function login() {
  form.submit()
}

function reset() {
  form.reset()
  document.getElementById('email')?.focus()
}
<form @submit.prevent="login" @reset.prevent="reset">
  <div>
    <label for="email">Email address</label>
    <input id="email" v-model="form.email" name="email" type="email" @change="form.validate('email')" />
    <span v-if="form.valid('email')">OK!!</span>
    <span v-if="form.invalid('email')">{{ form.errors.email }}</span>
  </div>

  <div>
    <label for="password">Password</label>
    <input
      id="password"
      v-model="form.password"
      name="password"
      type="password"
      autocomplete="current-password"
      required
      @change="form.validate('password')"
    />
    <span v-if="form.valid('password')">OK!!</span>
    <span v-if="form.invalid('password')">{{ form.errors.password }}</span>
  </div>

  <div>
    <button type="submit">Sign in</button>
    <button type="reset">Reset</button>
  </div>
</form>

Server side validation

Wait what about http errors? And how can we validate data but skipping next steps?

  1. update the default configuration.
// nuxt.config.ts

export default defineNuxtConfig({
  modules: [
    'nuxt-precognition'
  ],
  precognition: {
    backendValidation: true,
    enableNuxtClientErrorParser: true,
  },
})

Here we are instructing the module to:

  • add backend validation (precognitive-requests) when we request single key validation.
  • add global parser to translate NuxtErrors to ValidationErrors.
  1. Create a Nitro plugin to parse server errors:
// server/plugins/precognition.ts
import { ZodError } from 'zod'

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('request', (event) => {
    event.context.$precognition.errorParsers = [
      zodErrorParser
    ]
  })
})

Assuming we are using same validation library on backend (in this example zod), we need to translate zod errors to NuxtErrors the module will understand.

  1. Use definePrecognitiveEventHandler in the object way and add validation in the onRequest hook.
import { definePrecognitiveEventHandler, readBody } from '#imports'
// server/api/login.post.ts
import { z } from 'zod'

const loginSchema = z.object({
  email: z.string().email(),
  password: z.string()
}).refine((_data) => {
  // Check for email and password match
  // ...
  return true
}, { message: 'invalid credentials', path: ['email'] },)

export default definePrecognitiveEventHandler({
  async onRequest(event) {
    const body = await readBody(event)
    loginSchema.parse(body)
  },
  handler: () => {
    return {
      status: 200,
      body: {
        message: 'Success',
      },
    }
  },
})

Splitting the handler in different hooks, we can isolate validation from the main functionality.

When the precognitive-request is detected, only the validation function will run.

Custom Parsers per request

It happens you have a specific apis, where you can have errors in different shapes. For example using common authentication services with specific sdks.

No problem, you can define the parser on the useForm composable.

const form = useForm(
  (): z.infer<typeof UserSchema> => ({
    email: '',
    password: '',
  }),
  (body, headers) => $fetch('/api/login', {
    method: 'POST',
    headers,
    body,
  }),
  {
    clientValidation(data) {
      UserSchema.parse(data)
    },
    clientErrorParsers: [
      (error) => {
        if (error instanceof CustomError) {
          // ....
          return { errors, message: error.message }
        }
        //
        return null
      }
    ]
  },
)

This parser will be used only for this form submission.

Same thing on backend.

export default definePrecognitiveEventHandler({
  async onRequest(event) {
    const body = await readBody(event)
    loginSchema.parse(body)
  },
  handler: () => {
    return {
      status: 200,
      body: {
        message: 'Success',
      },
    }
  },
}, {
  errorParsers: [
    (error) => {
      if (error instanceof z.ZodError) {
        return {
          message: 'Invalid data',
          errors: error.issues.map(issue => ({
            path: issue.path.join('.'),
            message: issue.message,
          }))
        }
      }
    }
  ]
})

Asynchronous Form Submission (asyncSubmit)

Starting from v0.2.0, useForm provides asyncSubmit() as an alternative to submit().

Unlike submit(), asyncSubmit():

  • Returns the promise result (Promise<TResp>) directly upon success.
  • Accepts an optional { headers?: HeadersInit } parameter (supporting Headers, Record<string, string>, or [string, string][]).
  • Executes client-side validation (if configured).
  • Guarantees that validation errors are always assigned to form keys (form.errors, form.error) before re-throwing the error, allowing callers to handle errors using standard try / catch or pass them to external state managers while the form state remains fully reactive and updated.

Standard try / catch Example

async function handleSubmit() {
  try {
    const response = await form.asyncSubmit()
    console.log('Submission successful:', response)
  }
  catch (error) {
    // Validation errors are already assigned to form keys before this catch block runs
    console.error('Submission failed:', error)
  }
}

Integration with Pinia Colada (useMutation)

asyncSubmit is particularly useful when combined with data fetching & mutation libraries like Pinia Colada (@pinia/colada). Because asyncSubmit guarantees that validation errors are assigned to the form keys before re-throwing, you can pass form.asyncSubmit directly as the mutation function inside useMutation:

import { useMutation } from '@pinia/colada'
import { useForm } from 'nuxt-precognition'

const form = useForm(
  () => ({
    email: '',
    password: '',
  }),
  (body, headers) => $fetch('/api/login', {
    method: 'POST',
    headers,
    body,
  }),
  {
    clientValidation(data) {
      UserSchema.parse(data)
    },
  },
)

// Pass form.asyncSubmit directly as the mutation parameter
const { mutate, isLoading } = useMutation({
  mutation: () => form.asyncSubmit(),
  onSuccess(data) {
    // Handle success (e.g. navigate, show toast)
  },
  onError(error) {
    // form.errors and form.invalid() are already populated here
  },
})
Previous
Core