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.
Architecture overview
Section titled “Architecture overview”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 enhancementFor 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.
Zod v4 schema patterns
Section titled “Zod v4 schema patterns”All validation schemas live under src/lib/modules/<module>/schemas/ and are re-exported through barrel files. The project uses Zod v4 syntax exclusively.
String validation with .check()
Section titled “String validation with .check()”import { z } from "zod";
// v4 pattern: pass check helpers to .check()z.string().check( z.minLength(1, "Required"), z.maxLength(100, "Too long"),);
// Regex checksz.string().check( z.regex(/^[a-zA-Z0-9_]+$/, "Only letters, numbers, and underscores"),);Email validation
Section titled “Email validation”// v4: top-level z.email(), NOT z.string().email()z.email("Please enter a valid email address");Password schema (reusable base)
Section titled “Password schema (reusable base)”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.
Other common patterns
Section titled “Other common patterns”// Optional fields (matches undefined, NOT empty string)z.string().optional();
// Optional with defaultz.string().max(500).optional().default("");
// URL or empty stringz.string().url("Please enter a valid URL").or(z.literal("")).optional().default("");
// Enumz.enum(["draft", "published"]).default("published");
// UUIDz.string().uuid();
// superRefine for cross-field validationz.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"], }); } });Auth schemas
Section titled “Auth schemas”| Schema | File | Purpose |
|---|---|---|
signInSchema | sign-in.ts | Email + password (minLength 1) |
signUpSchema | sign-up.ts | First/last name, username, email, password |
usernameSchema | sign-up.ts | 3-30 chars, alphanumeric + underscores |
passwordSchema | password.ts | Shared password rules (8+ chars, mixed case, number, special) |
forgotPasswordSchema | forgot-password.ts | Email-only, triggers password reset email |
resetPasswordSchema | reset-password.ts | Wraps passwordSchema in an object |
resendVerificationSchema | resend-verification.ts | Email-only, resends signup confirmation |
profileSchema | profile.ts | Profile editing (name, username, bio, website, location) |
Type exports
Section titled “Type exports”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>;Schema file organization
Section titled “Schema file organization”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, typesThe 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”Adapter import
Section titled “Adapter import”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";Load function
Section titled “Load function”The load function creates an empty form for the client:
export const load: PageServerLoad = async () => { const form = await superValidate(zod(createRoomSchema)); return { form };};Form action
Section titled “Form action”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"); },};Simpler actions (auth forms)
Section titled “Simpler actions (auth forms)”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”Adapter import
Section titled “Adapter import”Client files import the Zod v4 client adapter aliased as zodClient:
import { superForm } from "sveltekit-superforms";import { zod4Client as zodClient } from "sveltekit-superforms/adapters";Setting up superForm
Section titled “Setting up superForm”<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:
| Value | Purpose |
|---|---|
$form | Reactive form data (bind to inputs) |
$errors | Per-field error messages |
enhance | Svelte use:enhance action for progressive enhancement |
$submitting | true while the form is submitting |
$message | Server-set message from message() |
Client-side validation flow
Section titled “Client-side validation flow”- User fills in the form.
$formvalues update reactively viabind:value. - User submits the form.
zodClient(schema)validates client-side before the request is sent. If invalid,$errorsupdates and submission is blocked.- If client-side validation passes,
use:enhancesends the request to the server action. - The server runs
superValidate(request, zod(schema)). If!form.valid, it returnsfail(400, { form }). - superforms merges the server response back into
$errorson the client. - On success,
onResultfires and the toast appears.
Building the form template
Section titled “Building the form template”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>Field component API
Section titled “Field component API”Import as a namespace:
import * as Field from "$lib/components/ui/field/index.js";| Component | Purpose |
|---|---|
Field.Field | Core wrapper for a single form field |
Field.Label | Label — associates with input via for |
Field.Description | Helper text below or beside a field |
Field.Error | Validation error message |
Field.Content | Flex column that groups label + description (for horizontal layouts) |
Field.Group | Stack multiple fields together |
Field.Set | Semantic <fieldset> wrapper |
Field.Legend | Fieldset legend / section title |
Field.Separator | Divider between field sections |
CSRF protection
Section titled “CSRF protection”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.
API endpoints (+server.ts) do not receive automatic CSRF protection. Honeycomb uses a utility at src/lib/utils/csrf.ts that performs origin-based validation:
import { csrfProtection } from "$lib/utils/csrf";
export const POST: RequestHandler = async (event) => { const csrfError = csrfProtection(event); if (csrfError) return csrfError; // 403 JSON response
// ... handle request};The csrfProtection function checks:
- Only state-changing methods (
POST,PUT,DELETE,PATCH) — GET/HEAD/OPTIONS pass through. - The
Originheader matchesevent.url.host. - Falls back to the
Refererheader ifOriginis absent. - Returns a
403JSON response on mismatch, ornullif valid.
There is also a verifyCsrfToken helper that returns a boolean (instead of a Response) and an isSameOrigin helper for additional security checks.
API endpoint pattern (non-page forms)
Section titled “API endpoint pattern (non-page forms)”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.tsexport 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 });};Rate limiting
Section titled “Rate limiting”API endpoints that are susceptible to abuse implement in-memory rate limiting:
| Endpoint | Max attempts | Window | Extra |
|---|---|---|---|
/auth/reset-password | 5 | 15 minutes | Per IP address |
/auth/resend-verification | 3 | 15 minutes | Per 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)”1. Define the schema
Section titled “1. Define the schema”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(),});2. Set up the server action
Section titled “2. Set up the server action”// src/routes/(app)/honeycomb/rooms/create/+page.server.tsimport { 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"); },};3. Build the component
Section titled “3. Build the component”<!-- 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>Rules and conventions
Section titled “Rules and conventions”- Use superforms + Zod for all form validation. Server-side via
superValidate(), client-side viazodClient(). - Use
Field.*components to wrap every form input. Never bare<label>+<input>. - Use
use:enhancefrom superforms for progressive enhancement. - Show loading state on submit buttons with a
Loader2spinner anddisabled={$submitting}. - Use
toastfromsvelte-sonnerfor success/error feedback. - Use Zod v4 syntax —
z.string().check(z.minLength(...)), notz.string().min(...). Usez.email(), notz.string().email(). - Convert empty strings in server actions:
fd.get("field") || undefined. - Server adapter:
import { zod4 as zod } from "sveltekit-superforms/adapters". - Client adapter:
import { zod4Client as zodClient } from "sveltekit-superforms/adapters". - No Formsnap — use superforms directly with the Field component system.