Data validation is the boundary between your application and the chaos of the outside world — form inputs, API payloads, environment variables, third-party webhooks. If a validation library is heavy, you pay for it on every page load. If it is not type-safe, you pay for it in production bugs.
Valibot solves both problems. It is a fully type-safe schema library that starts at around 1.3 kB gzipped and grows only with the validators you actually import. Because every validator is a separate function, bundlers tree-shake away everything you do not use. The result is a validation layer that feels like Zod but ships a fraction of the JavaScript.
In this tutorial you will build a complete validation layer for a Next.js 15 application: schemas, transformation pipelines, custom and async rules, error handling, and a fully validated Server Action form.
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed
- A Next.js 15 project using the App Router (or any TypeScript project)
- Basic knowledge of TypeScript generics and inference
- A code editor (VS Code recommended)
What You'll Build
A small "developer registration" feature with:
- A reusable schema module that validates names, emails, passwords, roles, and tags
- Type inference so your handlers never re-declare interfaces
- A Server Action that parses
FormDataand returns field-level errors - Async validation that checks an email against a database
- Environment-variable validation that fails fast at startup
By the end you will understand Valibot's mental model well enough to validate anything.
Step 1: Project Setup
Install Valibot. It has zero dependencies, so this is a single small package:
npm install valibot
# or
pnpm add valibotValibot ships ESM and CJS builds and full TypeScript types. No configuration is required.
The single most important thing to know about Valibot is its import style. Instead of a large object with methods, every function is a named export. The convention is to import the whole namespace as v:
import * as v from 'valibot';This is what makes tree-shaking work: when your bundler sees that you only used v.string and v.object, it drops the other 200+ validators from your bundle.
Step 2: Your First Schema
A schema describes the shape of valid data. Let's start with a login schema:
import * as v from 'valibot';
const LoginSchema = v.object({
email: v.pipe(v.string(), v.email()),
password: v.pipe(v.string(), v.minLength(8)),
});Two ideas are doing the work here:
v.objectdefines an object schema with typed entries.v.pipechains a base schema with one or more actions (validations and transformations). Readv.pipe(v.string(), v.email())as: "first it must be a string, then it must look like an email."
This composition is the heart of Valibot. A base schema (v.string, v.number, v.boolean, v.array, v.object) establishes the type. Actions piped after it refine the value without changing the type.
Step 3: Parsing Data
A schema is inert until you run data through it. Valibot gives you two ways to do that.
parse — throw on failure
// Returns the typed value, or throws a ValiError
const output = v.parse(LoginSchema, {
email: 'jane@example.com',
password: '12345678',
});Use parse when invalid data is genuinely exceptional — like an environment variable that must exist for the app to boot.
safeParse — return a result
const result = v.safeParse(LoginSchema, {
email: 'jane@example.com',
password: '12345678',
});
if (result.success) {
// result.output is fully typed
console.log(result.output.email);
} else {
// result.issues is an array of validation issues
console.log(result.issues);
}safeParse never throws. It returns a discriminated union with a success boolean, an output on success, and issues on failure. This is the right tool for user-facing input where errors are expected and must be displayed.
Prefer safeParse for anything a user touches (forms, query params, request bodies) and parse for trusted-but-required values (config, env vars). Throwing on a typo in a contact form is a poor experience; throwing on a missing database URL at startup is exactly right.
Step 4: Inferring TypeScript Types
Here is where Valibot pays off twice. You never need to write a separate interface — the schema is the source of truth:
const LoginSchema = v.object({
email: v.pipe(v.string(), v.email()),
password: v.pipe(v.string(), v.minLength(8)),
});
// { email: string; password: string }
type LoginData = v.InferOutput<typeof LoginSchema>;
function authenticate(data: LoginData) {
// data.email and data.password are typed and validated upstream
}There are two inference helpers, and the difference matters once you add transformations:
v.InferOutput— the type after parsing and transforming. This is what your code consumes.v.InferInput— the type before parsing. This is what a caller must provide.
Consider a schema that transforms a string into its length:
const ObjectSchema = v.object({
key: v.pipe(
v.string(),
v.transform((input) => input.length)
),
});
type Input = v.InferInput<typeof ObjectSchema>; // { key: string }
type Output = v.InferOutput<typeof ObjectSchema>; // { key: number }The input is a string; the output is a number. Use InferInput to type your function arguments and InferOutput to type the parsed result.
Step 5: Building a Realistic Schema
Let's design the schema for our developer registration. Create lib/schemas.ts:
import * as v from 'valibot';
export const RoleSchema = v.picklist(['frontend', 'backend', 'fullstack']);
export const RegistrationSchema = v.object({
name: v.pipe(
v.string(),
v.trim(),
v.nonEmpty('Please enter your name.'),
v.maxLength(60, 'Name is too long.')
),
email: v.pipe(
v.string(),
v.trim(),
v.toLowerCase(),
v.email('Please enter a valid email address.')
),
password: v.pipe(
v.string(),
v.minLength(8, 'Password must be at least 8 characters.'),
v.regex(/[A-Z]/, 'Include at least one uppercase letter.'),
v.regex(/[0-9]/, 'Include at least one number.')
),
role: RoleSchema,
tags: v.pipe(
v.array(v.pipe(v.string(), v.nonEmpty())),
v.maxLength(5, 'No more than 5 tags allowed.')
),
newsletter: v.optional(v.boolean(), false),
});
export type Registration = v.InferOutput<typeof RegistrationSchema>;A few new pieces worth highlighting:
v.picklistrestricts a value to a fixed set of literals — perfect for enums like roles.v.trimandv.toLowerCaseare transformation actions. They normalize the value as it flows through the pipe, so" JANE@EXAMPLE.COM "becomes"jane@example.com"before the email check runs.v.optional(schema, fallback)makes a field optional and supplies a default, sonewsletteris always a boolean in the output.- Every validation action takes an optional message as its last argument. Set these deliberately — they become your user-facing error copy.
Step 6: Custom Validation with check
Built-in actions cover most cases, but cross-field rules need custom logic. The v.check action runs an arbitrary predicate and fails with your message if it returns false:
import * as v from 'valibot';
const SignUpSchema = v.pipe(
v.object({
password: v.pipe(v.string(), v.minLength(8)),
confirmPassword: v.string(),
}),
v.check(
(input) => input.password === input.confirmPassword,
'Passwords do not match.'
)
);Notice that the v.check lives outside the v.object, wrapped by an outer v.pipe. That is because it needs the whole object to compare two fields. Field-level checks go inside the field's pipe; object-level checks wrap the object.
Here is another example validating that an array's declared length matches its contents:
const CustomObjectSchema = v.pipe(
v.object({
list: v.array(v.string()),
length: v.number(),
}),
v.check(
(input) => input.list.length === input.length,
'The list does not match the length.'
)
);Step 7: Async Validation
Some rules can only be answered by a database or an external API — for example, "is this email already taken?" Valibot supports asynchronous schemas through async variants of its functions: v.checkAsync, v.pipeAsync, v.objectAsync, and the v.parseAsync / v.safeParseAsync methods.
import * as v from 'valibot';
async function isEmailAvailable(email: string): Promise<boolean> {
// Replace with a real database lookup
const taken = await db.user.findUnique({ where: { email } });
return taken === null;
}
const UniqueEmailSchema = v.pipeAsync(
v.string(),
v.email('Please enter a valid email address.'),
v.checkAsync(isEmailAvailable, 'This email is already registered.')
);
// You must await an async schema:
const result = await v.safeParseAsync(UniqueEmailSchema, 'jane@example.com');Run synchronous validations before async ones in the same pipe. Valibot stops at the first failing action by default, so cheap checks (format, length) reject obviously bad input before you spend a database round-trip on it.
The rule of thumb: if any action in a pipe is async, the entire schema becomes async, and you must use the *Async parse methods. Keep async schemas separate from purely synchronous ones so you only pay the async cost where you need it.
Step 8: Error Handling with flatten
When safeParse fails, result.issues is a flat array of every problem found, each with a message and a path. For a form, you want errors grouped by field instead. The v.flatten helper does exactly that:
import * as v from 'valibot';
const FormSchema = v.object({
name: v.pipe(v.string(), v.nonEmpty('Name is required.')),
address: v.object({
city: v.pipe(v.string(), v.nonEmpty('City is required.')),
zip: v.pipe(v.string(), v.regex(/^\d{5}$/, 'Invalid zip code.')),
}),
});
const result = v.safeParse(FormSchema, {
name: '',
address: { city: '', zip: 'abc' },
});
if (!result.success) {
const flat = v.flatten<typeof FormSchema>(result.issues);
console.log(flat);
// {
// nested: {
// name: ['Name is required.'],
// 'address.city': ['City is required.'],
// 'address.zip': ['Invalid zip code.'],
// }
// }
}flatten returns up to three keys: root (issues on the top-level value), nested (issues keyed by dot-separated path), and other (issues without a path). For most forms you only read nested, mapping each path to the matching input.
Step 9: A Validated Next.js Server Action
Now we connect everything. In a Next.js 15 App Router project, a Server Action receives FormData, validates it with Valibot, and returns a typed state to the client.
Create app/register/actions.ts:
'use server';
import * as v from 'valibot';
import { RegistrationSchema } from '@/lib/schemas';
export type FormState = {
ok: boolean;
errors?: Record<string, [string, ...string[]]>;
message?: string;
};
export async function registerAction(
_prev: FormState,
formData: FormData
): Promise<FormState> {
// FormData values are strings; reshape before parsing.
const raw = {
name: formData.get('name'),
email: formData.get('email'),
password: formData.get('password'),
role: formData.get('role'),
tags: formData.getAll('tags'),
newsletter: formData.get('newsletter') === 'on',
};
const result = v.safeParse(RegistrationSchema, raw);
if (!result.success) {
const flat = v.flatten<typeof RegistrationSchema>(result.issues);
return { ok: false, errors: flat.nested ?? {} };
}
// result.output is a fully typed, normalized Registration object
await saveDeveloper(result.output);
return { ok: true, message: 'Welcome aboard!' };
}The form component uses React 19's useActionState to wire the action and render field errors:
'use client';
import { useActionState } from 'react';
import { registerAction, type FormState } from './actions';
const initial: FormState = { ok: false };
export function RegisterForm() {
const [state, action, pending] = useActionState(registerAction, initial);
return (
<form action={action} className="space-y-4">
<div>
<input name="name" placeholder="Full name" />
{state.errors?.name && <p className="error">{state.errors.name[0]}</p>}
</div>
<div>
<input name="email" type="email" placeholder="Email" />
{state.errors?.email && <p className="error">{state.errors.email[0]}</p>}
</div>
<div>
<input name="password" type="password" placeholder="Password" />
{state.errors?.password && (
<p className="error">{state.errors.password[0]}</p>
)}
</div>
<select name="role">
<option value="frontend">Frontend</option>
<option value="backend">Backend</option>
<option value="fullstack">Fullstack</option>
</select>
<label>
<input type="checkbox" name="newsletter" /> Subscribe to newsletter
</label>
<button disabled={pending}>
{pending ? 'Submitting...' : 'Register'}
</button>
{state.ok && <p className="success">{state.message}</p>}
</form>
);
}The same RegistrationSchema now guards both client and server. Because the schema is the single source of truth, there is no drift between what the form accepts and what your handler trusts.
Step 10: Validate Environment Variables
A classic production failure is a missing or malformed environment variable discovered at runtime. Validate them once, at module load, with parse so the app refuses to boot on bad config. Create lib/env.ts:
import * as v from 'valibot';
const EnvSchema = v.object({
DATABASE_URL: v.pipe(v.string(), v.url('DATABASE_URL must be a valid URL.')),
PORT: v.pipe(
v.optional(v.string(), '3000'),
v.transform(Number),
v.number(),
v.minValue(1)
),
NODE_ENV: v.picklist(['development', 'production', 'test']),
});
// Throws at import time if anything is missing or malformed.
export const env = v.parse(EnvSchema, process.env);This pattern catches misconfiguration before a single request is served. Note how v.transform(Number) turns the PORT string into a real number, and the subsequent v.number() and v.minValue(1) validate the transformed value — a clean demonstration of mixing transforms and validations in one pipe.
Step 11: Composing and Reusing Schemas
As your app grows, you compose schemas rather than rewrite them. Valibot ships utilities that mirror TypeScript's own type operators:
import * as v from 'valibot';
const UserSchema = v.object({
id: v.pipe(v.string(), v.uuid()),
name: v.string(),
email: v.pipe(v.string(), v.email()),
password: v.pipe(v.string(), v.minLength(8)),
});
// A public view without the password
const PublicUserSchema = v.omit(UserSchema, ['password']);
// Only the fields a sign-up form needs
const SignUpSchema = v.pick(UserSchema, ['name', 'email', 'password']);
// Everything optional, for a PATCH endpoint
const UserPatchSchema = v.partial(v.omit(UserSchema, ['id']));For values that can take multiple shapes, v.variant selects a branch by a discriminator key — ideal for tagged unions like webhook events:
const EventSchema = v.variant('type', [
v.object({ type: v.literal('created'), id: v.string() }),
v.object({ type: v.literal('deleted'), id: v.string(), reason: v.string() }),
]);These helpers keep one canonical schema and derive every variation from it, so a change to UserSchema ripples everywhere automatically.
Step 12: Standard Schema Interop
Valibot implements the Standard Schema specification — a shared interface adopted by Zod, ArkType, and others. In practice this means form libraries and routers accept a Valibot schema directly, with no adapter:
// Works with TanStack Form, react-hook-form (via the standard resolver),
// tRPC, and any tool that speaks Standard Schema.
import { useForm } from '@tanstack/react-form';
import { RegistrationSchema } from '@/lib/schemas';
const form = useForm({
validators: { onChange: RegistrationSchema },
});This interoperability is why you can adopt Valibot incrementally: swap it in behind the same interface your tools already expect.
Testing Your Implementation
Verify the core behaviors with a quick test using your runner of choice:
import * as v from 'valibot';
import { describe, it, expect } from 'vitest';
import { RegistrationSchema } from '@/lib/schemas';
describe('RegistrationSchema', () => {
it('normalizes and accepts valid input', () => {
const result = v.safeParse(RegistrationSchema, {
name: ' Jane ',
email: 'JANE@EXAMPLE.COM',
password: 'Secret123',
role: 'fullstack',
tags: ['react'],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.output.name).toBe('Jane');
expect(result.output.email).toBe('jane@example.com');
expect(result.output.newsletter).toBe(false);
}
});
it('reports field-level errors', () => {
const result = v.safeParse(RegistrationSchema, {
name: '',
email: 'nope',
password: 'short',
role: 'fullstack',
tags: [],
});
expect(result.success).toBe(false);
if (!result.success) {
const flat = v.flatten<typeof RegistrationSchema>(result.issues);
expect(flat.nested?.name).toBeDefined();
expect(flat.nested?.email).toBeDefined();
}
});
});Run it and confirm both cases pass. The first asserts that transforms run (trim, lowercase, default); the second asserts that flatten produces field-keyed messages.
Troubleshooting
"Type instantiation is excessively deep" on a large schema. This usually comes from deeply nested v.pipe chains. Extract sub-schemas into named constants and reference them; the smaller pieces type-check faster and read better.
An async check never runs. You probably called v.safeParse instead of v.safeParseAsync, or built the pipe with v.pipe instead of v.pipeAsync. Any async action requires the async parse method and the async pipe.
InferInput and InferOutput differ unexpectedly. That is by design when you use v.transform. Type your function arguments with InferInput (what callers send) and your results with InferOutput (what they receive after parsing).
Bundle larger than expected. Make sure you import as import * as v from 'valibot' and let your bundler tree-shake. Avoid re-exporting the entire namespace from a barrel file, which can defeat dead-code elimination.
Next Steps
- Add
v.brandto create nominal types (aUserIdthat is not interchangeable with any other string). - Explore
v.fallbackto recover gracefully from invalid values instead of failing. - Wire your schemas into a tRPC or Hono API so request bodies are validated at the edge.
- Compare bundle impact against your current validator with a tool like
bundlejsto quantify the savings.
Conclusion
Valibot gives you Zod-grade ergonomics and full TypeScript inference while shipping a fraction of the JavaScript, thanks to its modular, tree-shakeable design. You now have a complete pattern: define schemas once, derive types with InferOutput, parse safely with safeParse, group errors with flatten, handle async rules with the *Async family, and guard a Next.js Server Action end-to-end.
The bigger lesson is architectural: when a single schema is the source of truth for your types, your forms, your API, and your config, entire categories of bugs simply cannot occur. Validate at the boundary, infer everywhere else, and let the compiler carry the rest.