Validation & Error Handling
Overview
Every form submission must be validated on the server. Client-side validation is a convenience for the user, not a security boundary. Anyone can bypass it by disabling JavaScript, editing the DOM, or sending a crafted HTTP request. Server-side validation in SvelteKit form actions is the authority. The pattern is: validate on the server, return field-specific errors with fail(), and display them in the form component.
Server-Side Validation in Actions
The simplest approach is manual validation in the action handler.
// src/routes/register/+page.server.ts
import type { Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import { db } from '$lib/server/database';
import { hashPassword } from '$lib/server/auth';
export const actions: Actions = {
default: async ({ request }) => {
const formData = await request.formData();
const name = (formData.get('name') as string)?.trim();
const email = (formData.get('email') as string)?.trim().toLowerCase();
const password = formData.get('password') as string;
const errors: Record<string, string> = {};
if (!name) {
errors.name = 'Name is required';
} else if (name.length < 2) {
errors.name = 'Name must be at least 2 characters';
}
if (!email) {
errors.email = 'Email is required';
} else if (!/^[^@]+@[^@]+\.[^@]+$/.test(email)) {
errors.email = 'Invalid email address';
}
if (!password) {
errors.password = 'Password is required';
} else if (password.length < 8) {
errors.password = 'Password must be at least 8 characters';
}
if (Object.keys(errors).length > 0) {
return fail(400, { errors, name, email });
}
// Check for existing user after basic validation passes
const existing = await db.user.findUnique({ where: { email } });
if (existing) {
return fail(409, { errors: { email: 'Email already registered' }, name, email });
}
await db.user.create({
data: {
name,
email,
passwordHash: await hashPassword(password)
}
});
redirect(303, '/login?registered=true');
}
};
Returning fail() with Field-Specific Errors
The fail() function returns data to the page without a redirect. The first argument is the HTTP status code. The second is any data you want the form to receive.
// Return errors mapped to field names
return fail(400, {
errors: {
name: 'Name is required',
email: 'Invalid email format',
password: 'Must be at least 8 characters'
},
// Return submitted values so the form can repopulate
name,
email
// Never return the password
});
The structure is up to you. A common convention is an errors object keyed by field name, plus the submitted values for non-sensitive fields.
Displaying Errors in the Form
The page component receives the action's return value through the form prop.
<!-- src/routes/register/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms';
let { form } = $props();
</script>
<h1>Create Account</h1>
<form method="POST" use:enhance>
<div class="field">
<label for="name">Name</label>
<input
id="name"
name="name"
value={form?.name ?? ''}
aria-invalid={form?.errors?.name ? 'true' : undefined}
aria-describedby={form?.errors?.name ? 'name-error' : undefined}
/>
{#if form?.errors?.name}
<p id="name-error" class="error">{form.errors.name}</p>
{/if}
</div>
<div class="field">
<label for="email">Email</label>
<input
id="email"
name="email"
type="email"
value={form?.email ?? ''}
aria-invalid={form?.errors?.email ? 'true' : undefined}
aria-describedby={form?.errors?.email ? 'email-error' : undefined}
/>
{#if form?.errors?.email}
<p id="email-error" class="error">{form.errors.email}</p>
{/if}
</div>
<div class="field">
<label for="password">Password</label>
<input
id="password"
name="password"
type="password"
aria-invalid={form?.errors?.password ? 'true' : undefined}
aria-describedby={form?.errors?.password ? 'password-error' : undefined}
/>
{#if form?.errors?.password}
<p id="password-error" class="error">{form.errors.password}</p>
{/if}
</div>
<button type="submit">Register</button>
</form>
The aria-invalid and aria-describedby attributes connect each field to its error message for screen readers. This is not optional polish. It is how users of assistive technology know which field has an error and what the error says.
Schema Validation with Zod
Manual validation works but gets verbose as forms grow. Zod provides a declarative way to define and validate schemas.
// src/lib/schemas/user.ts
import { z } from 'zod';
export const registerSchema = z.object({
name: z
.string()
.min(2, 'Name must be at least 2 characters')
.max(100, 'Name must be under 100 characters'),
email: z
.string()
.email('Invalid email address')
.max(255, 'Email must be under 255 characters'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.max(128, 'Password must be under 128 characters')
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
.regex(/[0-9]/, 'Password must contain a number')
});
export type RegisterInput = z.infer<typeof registerSchema>;
// src/routes/register/+page.server.ts
import type { Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import { registerSchema } from '$lib/schemas/user';
import { db } from '$lib/server/database';
import { hashPassword } from '$lib/server/auth';
export const actions: Actions = {
default: async ({ request }) => {
const formData = await request.formData();
const raw = {
name: formData.get('name') as string,
email: formData.get('email') as string,
password: formData.get('password') as string
};
const result = registerSchema.safeParse(raw);
if (!result.success) {
const errors: Record<string, string> = {};
for (const issue of result.error.issues) {
const field = issue.path[0] as string;
if (!errors[field]) {
errors[field] = issue.message;
}
}
return fail(400, { errors, name: raw.name, email: raw.email });
}
const { name, email, password } = result.data;
const existing = await db.user.findUnique({ where: { email } });
if (existing) {
return fail(409, {
errors: { email: 'Email already registered' },
name,
email
});
}
await db.user.create({
data: {
name,
email,
passwordHash: await hashPassword(password)
}
});
redirect(303, '/login?registered=true');
}
};
Reusable Validation Helper
Extract the FormData-to-object conversion and validation into a helper.
// src/lib/server/validate.ts
import type { z } from 'zod';
import { fail } from '@sveltejs/kit';
export async function validateForm<T extends z.ZodObject<any>>(
request: Request,
schema: T
): Promise<
| { success: true; data: z.infer<T> }
| { success: false; error: ReturnType<typeof fail> }
> {
const formData = await request.formData();
const raw: Record<string, unknown> = {};
for (const [key, value] of formData.entries()) {
raw[key] = value;
}
const result = schema.safeParse(raw);
if (!result.success) {
const errors: Record<string, string> = {};
for (const issue of result.error.issues) {
const field = issue.path[0] as string;
if (!errors[field]) {
errors[field] = issue.message;
}
}
// Remove sensitive fields before returning
const safeValues = { ...raw };
delete safeValues.password;
delete safeValues.secret;
return {
success: false,
error: fail(400, { errors, ...safeValues })
};
}
return { success: true, data: result.data };
}
// src/routes/register/+page.server.ts
import { validateForm } from '$lib/server/validate';
import { registerSchema } from '$lib/schemas/user';
export const actions: Actions = {
default: async ({ request }) => {
const validation = await validateForm(request, registerSchema);
if (!validation.success) return validation.error;
const { name, email, password } = validation.data;
// proceed with validated, typed data
}
};
Schema Validation with Valibot
Valibot is a lighter alternative to Zod with a similar API but smaller bundle size.
// src/lib/schemas/contact.ts
import * as v from 'valibot';
export const contactSchema = v.object({
name: v.pipe(
v.string(),
v.minLength(1, 'Name is required'),
v.maxLength(100, 'Name must be under 100 characters')
),
email: v.pipe(
v.string(),
v.email('Invalid email address')
),
message: v.pipe(
v.string(),
v.minLength(10, 'Message must be at least 10 characters'),
v.maxLength(5000, 'Message must be under 5000 characters')
)
});
export type ContactInput = v.InferOutput<typeof contactSchema>;
// src/routes/contact/+page.server.ts
import type { Actions } from './$types';
import { fail } from '@sveltejs/kit';
import * as v from 'valibot';
import { contactSchema } from '$lib/schemas/contact';
export const actions: Actions = {
default: async ({ request }) => {
const formData = await request.formData();
const raw = {
name: formData.get('name') as string,
email: formData.get('email') as string,
message: formData.get('message') as string
};
const result = v.safeParse(contactSchema, raw);
if (!result.success) {
const errors: Record<string, string> = {};
for (const issue of result.issues) {
const field = issue.path?.[0]?.key as string;
if (field && !errors[field]) {
errors[field] = issue.message;
}
}
return fail(400, { errors, ...raw });
}
await sendContactEmail(result.output);
return { success: true };
}
};
The Pattern: Validate on Server, Display on Client
The entire flow follows a predictable cycle.
1. User fills out form
2. Form submits to server (POST)
3. Action extracts form data
4. Schema validates the data
5. If invalid: return fail() with errors + submitted values
6. If valid: perform the mutation, then redirect
7. Page re-renders with form prop containing errors
8. Errors display next to their fields
9. User corrects and resubmits
<!-- The display pattern is always the same -->
<div class="field">
<label for="fieldname">Label</label>
<input
id="fieldname"
name="fieldname"
value={form?.fieldname ?? ''}
aria-invalid={form?.errors?.fieldname ? 'true' : undefined}
/>
{#if form?.errors?.fieldname}
<p class="error">{form.errors.fieldname}</p>
{/if}
</div>
This pattern scales to any form size. Each field follows the same structure: label, input with repopulated value, conditional error message.
Never Trust Client-Side Validation Alone
HTML validation attributes and JavaScript checks improve the user experience. They are not a security measure.
<!-- Client-side validation: good for UX, not for security -->
<input
name="email"
type="email"
required
maxlength="255"
/>
<!-- A user can remove 'required' in DevTools and submit empty -->
<!-- A script can POST directly to the action endpoint -->
// Server-side validation: the actual authority
export const actions: Actions = {
default: async ({ request }) => {
const formData = await request.formData();
const email = formData.get('email') as string;
// This validation cannot be bypassed
if (!email || !email.includes('@')) {
return fail(400, { errors: { email: 'Valid email required' } });
}
// This check cannot be bypassed
const existing = await db.user.findUnique({ where: { email } });
if (existing) {
return fail(409, { errors: { email: 'Already registered' } });
}
}
};
Client validation runs in the user's browser, which you do not control. Server validation runs on your server, which you do control. Both are necessary: client validation for responsiveness, server validation for correctness.
Common Pitfalls
- Trusting client-side validation as a security boundary: HTML
requiredandtype="email"attributes are trivially bypassable. Always validate on the server. - Returning passwords or secrets in fail(): Never include sensitive form fields in the fail response. They appear in the HTML and can be cached or logged.
- Showing only generic error messages: "Invalid input" tells the user nothing. Show specific, actionable messages: "Email must include an @ sign" or "Password must be at least 8 characters."
- Not handling database constraint errors: Even after validation, a database insert can fail (unique constraint, foreign key, etc.). Catch these errors and return meaningful messages.
- Validating only on first submission: If you clear errors when the form re-renders but the user submits the same invalid data, the errors must reappear. The
formprop handles this naturally since it updates on every submission. - Over-engineering validation: For a form with two fields, manual validation is fine. Reach for Zod or Valibot when you have complex schemas, nested objects, or want to share validation logic between client and server.
Key Takeaways
- Server-side validation in form actions is the authority. Client-side validation improves UX but cannot be trusted for correctness or security.
- Use
fail()to return field-specific errors and submitted values. The page re-renders with these available through theformprop. - Zod and Valibot provide declarative schema validation that produces typed, validated data. Extract schemas into
$lib/schemas/for reuse across actions. - The display pattern is consistent: for each field, show the label, the input with repopulated value, and a conditional error message linked with
aria-describedby. - Always return submitted values (except sensitive fields like passwords) when validation fails. Users should not have to re-type data because one field had an error.