Skip to content

Forms & Validation

Honeycomb uses sveltekit-superforms with Zod v4 for all form handling. Every form follows a three-layer pattern: a Zod schema defines the shape and rules, a +page.server.ts action validates on the server, and a Svelte component validates on the client with zodClient. Auth forms use a simpler manual safeParse pattern where superforms overhead is unnecessary.

schema (Zod v4)
|
+-- +page.server.ts superValidate(request, zod(schema))
| load() -> returns empty form
| actions -> validates, writes to DB, returns message
|
+-- +page.svelte / component superForm(data.form, { validators: zodClient(schema) })
$form -> bound values
$errors -> per-field errors
enhance -> progressive enhancement

For standalone API endpoints (+server.ts), the pattern uses manual safeParse with explicit CSRF protection since they fall outside SvelteKit’s built-in form action safeguards.

All validation schemas live under src/lib/modules/<module>/schemas/ and are re-exported through barrel files. The project uses Zod v4 syntax exclusively.

import { z } from "zod";
// v4 pattern: pass check helpers to .check()
z.string().check(
z.minLength(1, "Required"),
z.maxLength(100, "Too long"),
);
// Regex checks
z.string().check(
z.regex(/^[a-zA-Z0-9_]+$/, "Only letters, numbers, and underscores"),
);
// v4: top-level z.email(), NOT z.string().email()
z.email("Please enter a valid email address");

The shared password schema in src/lib/modules/auth/schemas/password.ts demonstrates stacking multiple checks:

import { z } from "zod";
export const passwordSchema = z.string().check(
z.minLength(8, "Password must be at least 8 characters long"),
z.maxLength(100, "Password must be less than 100 characters"),
z.regex(/[A-Z]/, "Password must contain at least one uppercase letter"),
z.regex(/[a-z]/, "Password must contain at least one lowercase letter"),
z.regex(/[0-9]/, "Password must contain at least one number"),
z.regex(
/[^A-Za-z0-9]/,
"Password must contain at least one special character"
),
);

This single schema is imported by both signUpSchema and resetPasswordSchema, keeping password rules consistent across the entire app.

// Optional fields (matches undefined, NOT empty string)
z.string().optional();
// Optional with default
z.string().max(500).optional().default("");
// URL or empty string
z.string().url("Please enter a valid URL").or(z.literal("")).optional().default("");
// Enum
z.enum(["draft", "published"]).default("published");
// UUID
z.string().uuid();
// superRefine for cross-field validation
z.object({ content: z.string(), title: z.string().optional() })
.superRefine((data, ctx) => {
if (!data.content.trim() && !data.title?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Post cannot be empty",
path: ["content"],
});
}
});
SchemaFilePurpose
signInSchemasign-in.tsEmail + password (minLength 1)
signUpSchemasign-up.tsFirst/last name, username, email, password
usernameSchemasign-up.ts3-30 chars, alphanumeric + underscores
passwordSchemapassword.tsShared password rules (8+ chars, mixed case, number, special)
forgotPasswordSchemaforgot-password.tsEmail-only, triggers password reset email
resetPasswordSchemareset-password.tsWraps passwordSchema in an object
resendVerificationSchemaresend-verification.tsEmail-only, resends signup confirmation
profileSchemaprofile.tsProfile editing (name, username, bio, website, location)

Inferred types are exported from src/lib/modules/auth/types/index.ts:

export type SignInSchema = z.infer<typeof signInSchema>;
export type SignUpSchema = z.infer<typeof signUpSchema>;
export type ResetPasswordSchema = z.infer<typeof resetPasswordSchema>;

Schemas live inside the relevant module under schemas/:

src/lib/modules/
auth/
schemas/
password.ts # Shared password validation
sign-in.ts # signInSchema
sign-up.ts # signUpSchema, usernameSchema
forgot-password.ts # forgotPasswordSchema
reset-password.ts # resetPasswordSchema
profile.ts # profileSchema
index.ts # re-exports everything
components/
login-form.svelte
signup-form.svelte
forgot-password-form.svelte
reset-password-form.svelte
index.ts # re-exports components
types/
index.ts # re-exports inferred Zod types
index.ts # barrel: re-exports schemas, components, types

The module index.ts re-exports everything so consumers import from the module root:

import { signInSchema, signUpSchema, LoginForm } from "$lib/modules/auth";

Server-side with superforms: +page.server.ts

Section titled “Server-side with superforms: +page.server.ts”

Server files import the Zod v4 adapter aliased as zod:

import { superValidate, message } from "sveltekit-superforms";
import { zod4 as zod } from "sveltekit-superforms/adapters";
import { fail } from "@sveltejs/kit";

The load function creates an empty form for the client:

export const load: PageServerLoad = async () => {
const form = await superValidate(zod(createRoomSchema));
return { form };
};

Actions validate the incoming request and return either a failure with the form (to re-render with errors) or a success message:

export const actions: Actions = {
default: async ({ request, locals }) => {
const { user } = await locals.safeGetSession();
if (!user) return fail(401);
const form = await superValidate(request, zod(createRoomSchema));
if (!form.valid) return fail(400, { form });
const { error } = await locals.supabase
.from("rooms")
.insert({ creator_id: user.id, name: form.data.name });
if (error) {
return message(form, error.message, { status: 500 });
}
redirect(303, "/rooms");
},
};

Auth pages like sign-in and sign-up use a simpler pattern without superforms: manual formData extraction with safeParse() and fail():

export const actions: Actions = {
login: async (event) => {
const formData = await event.request.formData();
const email = formData.get("email");
const password = formData.get("password");
const parsed = signInSchema.safeParse({ email, password });
if (!parsed.success) {
const message = parsed.error.issues[0]?.message ?? "Invalid input";
return fail(400, { message });
}
const { error } = await event.locals.supabase.auth.signInWithPassword({
email: parsed.data.email,
password: parsed.data.password,
});
if (error) return fail(400, { message: error.message });
throw redirect(303, "/");
},
};

A single page can define multiple named actions. The sign-in page exposes both ?/login and ?/google, each triggered by a different submit button’s formaction attribute.

Client-side with superforms: Svelte component

Section titled “Client-side with superforms: Svelte component”

Client files import the Zod v4 client adapter aliased as zodClient:

import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
<script lang="ts">
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { createRoomSchema } from "$lib/modules/honeycomb/rooms/index.js";
import { toast } from "svelte-sonner";
let { data } = $props();
const { form, errors, enhance, submitting, message } = superForm(data.form, {
validators: zodClient(createRoomSchema),
onResult: ({ result }) => {
if (result.type === "success") toast.success("Room created!");
},
});
</script>

Key destructured values:

ValuePurpose
$formReactive form data (bind to inputs)
$errorsPer-field error messages
enhanceSvelte use:enhance action for progressive enhancement
$submittingtrue while the form is submitting
$messageServer-set message from message()
  1. User fills in the form. $form values update reactively via bind:value.
  2. User submits the form.
  3. zodClient(schema) validates client-side before the request is sent. If invalid, $errors updates and submission is blocked.
  4. If client-side validation passes, use:enhance sends the request to the server action.
  5. The server runs superValidate(request, zod(schema)). If !form.valid, it returns fail(400, { form }).
  6. superforms merges the server response back into $errors on the client.
  7. On success, onResult fires and the toast appears.

Forms use the Field component system from shadcn-svelte:

<form method="POST" action="?/create" use:enhance>
<Field.Group>
<Field.Field data-invalid={$errors.name ? true : undefined}>
<Field.Label for="name">Name</Field.Label>
<Input
id="name"
name="name"
bind:value={$form.name}
aria-invalid={$errors.name ? true : undefined}
/>
{#if $errors.name}<Field.Error>{$errors.name}</Field.Error>{/if}
</Field.Field>
<Field.Field orientation="horizontal">
<Button type="submit" disabled={$submitting}>
{#if $submitting}<Loader2 class="size-4 animate-spin" />{/if}
Create
</Button>
</Field.Field>
</Field.Group>
</form>

Import as a namespace:

import * as Field from "$lib/components/ui/field/index.js";
ComponentPurpose
Field.FieldCore wrapper for a single form field
Field.LabelLabel — associates with input via for
Field.DescriptionHelper text below or beside a field
Field.ErrorValidation error message
Field.ContentFlex column that groups label + description (for horizontal layouts)
Field.GroupStack multiple fields together
Field.SetSemantic <fieldset> wrapper
Field.LegendFieldset legend / section title
Field.SeparatorDivider between field sections

SvelteKit form actions have built-in CSRF protection. The framework automatically validates the Origin header on every POST to a form action. No additional code is needed.

Flows like forgot-password and resend-verification use +server.ts endpoints instead of form actions because they are called from client-side fetch rather than native form submissions:

// src/routes/(auth)/auth/forgot-password/+server.ts
export const POST: RequestHandler = async (event) => {
// 1. CSRF check
const csrfError = csrfProtection(event);
if (csrfError) return csrfError;
// 2. Parse JSON body with Zod
const body = await event.request.json();
const parsed = forgotPasswordSchema.safeParse(body);
if (!parsed.success) {
return json({ success: false, message: parsed.error.issues[0]?.message }, { status: 400 });
}
// 3. Supabase operation
const { error } = await event.locals.supabase.auth.resetPasswordForEmail(
parsed.data.email,
{ redirectTo: `${event.url.origin}/auth/confirm` },
);
if (error) {
return json({ success: false, message: error.message }, { status: 400 });
}
return json({ success: true, email: parsed.data.email });
};

API endpoints that are susceptible to abuse implement in-memory rate limiting:

EndpointMax attemptsWindowExtra
/auth/reset-password515 minutesPer IP address
/auth/resend-verification315 minutesPer IP + email combo, plus 2-minute cooldown between requests

Full example: creating a room (schema to component)

Section titled “Full example: creating a room (schema to component)”
src/lib/modules/honeycomb/rooms/schemas/create-room.ts
import { z } from "zod";
export const createRoomSchema = z.object({
name: z.string().check(z.minLength(1, "Room name is required"), z.maxLength(100)),
description: z.string().max(500).optional(),
type: z.enum(["public", "private", "password"]).default("public"),
password: z.string().optional(),
requires_approval: z.boolean().default(false),
max_members: z.number().min(1).optional(),
});
// src/routes/(app)/honeycomb/rooms/create/+page.server.ts
import { superValidate, message } from "sveltekit-superforms";
import { zod4 as zod } from "sveltekit-superforms/adapters";
import { fail, redirect } from "@sveltejs/kit";
import { createRoomSchema } from "$lib/modules/honeycomb/rooms/index.js";
export const load: PageServerLoad = async () => {
const form = await superValidate(zod(createRoomSchema));
return { form };
};
export const actions: Actions = {
default: async ({ request, locals }) => {
const { user } = await locals.safeGetSession();
if (!user) return fail(401);
const form = await superValidate(request, zod(createRoomSchema));
if (!form.valid) return fail(400, { form });
const { error } = await locals.supabase
.schema("ext_honeycomb")
.from("rooms")
.insert({
creator_id: user.id,
name: form.data.name,
description: form.data.description || null,
type: form.data.type,
});
if (error) return message(form, error.message, { status: 500 });
redirect(303, "/honeycomb/rooms");
},
};
<!-- src/routes/(app)/honeycomb/rooms/create/+page.svelte -->
<script lang="ts">
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import * as Field from "$lib/components/ui/field/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Textarea } from "$lib/components/ui/textarea/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { toast } from "svelte-sonner";
import Loader2 from "@tabler/icons-svelte/icons/loader-2";
import { createRoomSchema } from "$lib/modules/honeycomb/rooms/index.js";
let { data } = $props();
const { form, errors, enhance, submitting } = superForm(data.form, {
validators: zodClient(createRoomSchema),
onResult: ({ result }) => {
if (result.type === "success") toast.success("Room created!");
},
});
</script>
<form method="POST" use:enhance>
<Field.Group>
<Field.Field data-invalid={$errors.name ? true : undefined}>
<Field.Label for="name">Room Name</Field.Label>
<Input id="name" name="name" bind:value={$form.name}
aria-invalid={$errors.name ? true : undefined} />
{#if $errors.name}<Field.Error>{$errors.name}</Field.Error>{/if}
</Field.Field>
<Field.Field>
<Field.Label for="description">Description</Field.Label>
<Textarea id="description" name="description"
value={String($form.description ?? "")}
placeholder="What's this room about?" rows={3} class="resize-none" />
</Field.Field>
<Field.Field orientation="horizontal">
<Button type="submit" disabled={$submitting}>
{#if $submitting}<Loader2 class="mr-1 size-4 animate-spin" />{/if}
Create Room
</Button>
<Button variant="outline" type="button" href="/honeycomb/rooms">
Cancel
</Button>
</Field.Field>
</Field.Group>
</form>
  1. Use superforms + Zod for all form validation. Server-side via superValidate(), client-side via zodClient().
  2. Use Field.* components to wrap every form input. Never bare <label> + <input>.
  3. Use use:enhance from superforms for progressive enhancement.
  4. Show loading state on submit buttons with a Loader2 spinner and disabled={$submitting}.
  5. Use toast from svelte-sonner for success/error feedback.
  6. Use Zod v4 syntaxz.string().check(z.minLength(...)), not z.string().min(...). Use z.email(), not z.string().email().
  7. Convert empty strings in server actions: fd.get("field") || undefined.
  8. Server adapter: import { zod4 as zod } from "sveltekit-superforms/adapters".
  9. Client adapter: import { zod4Client as zodClient } from "sveltekit-superforms/adapters".
  10. No Formsnap — use superforms directly with the Field component system.