Skip to content

SvelteKit form validation with Zod 4 and Svelte 5

Justin Ahinon Updated

Resources

Validate form data on the server, return field errors with SvelteKit’s fail(), and render those errors beside the inputs. Zod handles the schema; SvelteKit handles the POST and the response. You do not need a form library for a small form like this one.

Updated September 14, 2026. The example below was checked with SvelteKit 2.70.3, Svelte 5.57.0 and Zod 4.6.5. It replaces the old invalid() action response and Zod 3 error examples from the original article.

What this example does

We will validate a developer profile with a name, email and role. Invalid submissions keep the entered values and show field errors. A valid submission displays a confirmation that validation passed. This demo does not create an account, save a profile or send an email.

The example uses SvelteKit form actions. A normal HTML POST works before JavaScript loads. use:enhance adds submission without a full-page navigation. If you are using remote functions, see the distinction below before copying the files.

Define the Zod schema

Inside an existing SvelteKit TypeScript project, install the tested Zod version. Keep your framework dependencies on versions compatible with your app; this example uses Svelte 5’s $props() syntax.

bun add zod@4.6.5

Create src/lib/talent.ts. The role list is shared with the UI so the options and server validation agree.

import * as z from 'zod';

const roles = ['frontend-engineer', 'backend-engineer', 'fullstack-engineer', 'architect'] as const;
const talentSchema = z.object({
  name: z.string().trim().min(1, 'Enter your name.').max(100),
  email: z.string().trim().max(254).pipe(z.email('Enter a valid email address.')),
  role: z.enum(roles, { error: 'Choose a role from the list.' })
});

export { roles, talentSchema };

trim() runs before the name and email checks, so whitespace-only names fail. The email uses a trimmed, bounded string piped into Zod 4’s z.email(). z.enum() rejects a made-up role even if someone bypasses the select element.

Zod’s basic usage guide explains safeParse(): it returns either parsed data or a validation error. For a form, that result is easier to branch on than catching an exception for every invalid submission.

Validate the POST on the server

Create src/routes/+page.server.ts. If your page already has actions, merge this handler into the existing export. Do not mix a default action with named actions on the same page.

import { fail } from '@sveltejs/kit';
import * as z from 'zod';
import { talentSchema } from '$lib/talent';
import type { Actions } from './$types';

const actions = {
  default: async ({ request }) => {
    const data = await request.formData();
    const values = {
      name: typeof data.get('name') === 'string' ? String(data.get('name')).slice(0, 101) : '',
      email: typeof data.get('email') === 'string' ? String(data.get('email')).slice(0, 255) : '',
      role: typeof data.get('role') === 'string' ? String(data.get('role')).slice(0, 100) : ''
    };
    const result = talentSchema.safeParse(values);

    if (!result.success) {
      return fail(400, { success: false, values, errors: z.flattenError(result.error).fieldErrors });
    }

    // Validation demo only. Persist result.data before reporting a real save.
    return { success: true, values: result.data, errors: undefined };
  }
} satisfies Actions;

export { actions };

FormData.get() can return a string, a File or null. This action accepts text fields only and explicitly reads the three permitted names. It caps values echoed back to the page while retaining one character beyond each schema limit, so oversized name and email submissions still fail rather than silently passing after truncation. These caps do not limit the total incoming request body; configure that at your server or hosting layer.

For this flat object, Zod’s `flattenError()` provides arrays under fieldErrors.name, fieldErrors.email and fieldErrors.role. Nested schemas need a different display strategy, such as treeifyError(), so you do not lose the path to an invalid nested value.

The action returns only plain data, not the ZodError instance. fail(400, ...) marks an expected validation failure and makes its data available to the page. Use result.data for a database write or email operation after validation. Wait for that operation to succeed before telling the visitor that anything was saved or sent.

Show field errors and preserve the values

Create src/routes/+page.svelte. This is the complete page for the example; no separate client-side schema validator is required.

<script lang="ts">
  import { enhance } from '$app/forms';
  import { roles } from '$lib/talent';
  import type { PageProps } from './$types';

  let { form }: PageProps = $props();
  const errors = $derived(form?.errors);
</script>

<h1>Add your developer profile</h1>
<form method="POST" use:enhance>
  <label for="name">Name</label>
  <input id="name" name="name" required maxlength="100" autocomplete="name"
    value={form?.values.name ?? ''} aria-invalid={!!errors?.name}
    aria-describedby={errors?.name ? 'name-error' : undefined} />
  {#if errors?.name}<p id="name-error">{errors.name.join(' ')}</p>{/if}

  <label for="email">Email</label>
  <input id="email" name="email" type="email" required maxlength="254" autocomplete="email"
    value={form?.values.email ?? ''} aria-invalid={!!errors?.email}
    aria-describedby={errors?.email ? 'email-error' : undefined} />
  {#if errors?.email}<p id="email-error">{errors.email.join(' ')}</p>{/if}

  <label for="role">Role</label>
  <select id="role" name="role" required aria-invalid={!!errors?.role}
    aria-describedby={errors?.role ? 'role-error' : undefined}>
    <option value="" selected={!roles.some((role) => role === form?.values.role)}>Choose a role</option>
    {#each roles as role}
      <option value={role} selected={form?.values.role === role}>{role.replaceAll('-', ' ')}</option>
    {/each}
  </select>
  {#if errors?.role}<p id="role-error">{errors.role.join(' ')}</p>{/if}

  <button type="submit">Validate profile</button>
  {#if form?.success}<p role="status">Your profile is valid. This demo does not save it.</p>{/if}
  {#if form && !form.success}<p role="alert">Please correct the highlighted fields.</p>{/if}
</form>

The method="POST" attribute matters. Without it, the browser defaults to GET and will not call the action. PageProps types the action result, while $derived updates the displayed errors after submission.

Each input has a visible label. Invalid controls use aria-invalid, and aria-describedby points to the corresponding error. The form also announces that corrections are needed. Native required, maxlength and email validation help visitors catch simple mistakes, but the server still validates independently.

After a failed submission, the returned values refill the name and email controls and keep a valid selected role. A fabricated role cannot become a new option: the select returns to its prompt and displays the server error. After a successful enhanced submission, SvelteKit’s default enhancement resets the form; change that behavior deliberately if your real workflow should keep a saved value visible.

Test the cases that HTML validation can hide

  • Submit an empty or whitespace-only name directly to the server: expect a validation failure.

  • Send email=bad and a fabricated role: expect separate email and role messages.

  • Send a valid profile with surrounding spaces: expect parsed, trimmed name and email values.

  • Send a name longer than 100 characters: expect rejection even if the browser’s maxlength was bypassed.

  • Disable JavaScript and submit: the server-rendered response should still show the submitted values and errors.

  • In a real save flow, simulate a database or email failure and check that the page does not claim success.

For this refresh, the complete example passed svelte-check with no errors or warnings and a production build. HTTP tests exercised invalid input, field-error rendering, value retention, trimming and an overlong name using ordinary HTML POST requests. Those checks cover the server-rendered path; they are not a browser accessibility audit or a test of your database integration.

Form actions, remote functions or Superforms?

Form actions are a small dependency surface for a route-owned form. Remote functions provide a different interface through form() in a .remote.ts file, with schema validation and typed fields. As of this refresh, the official documentation still marks remote functions experimental. Their forms can also work without JavaScript.

Pick one API for a particular submission. The action example above imports enhance from $app/forms; a remote form uses its own form object and enhancement API. Do not copy the action response shape into a remote form and assume the error rendering is interchangeable. Check your installed SvelteKit version before adopting an experimental API.

For larger forms with repeated fields, nested objects and extensive client validation, a library such as Superforms can be worth evaluating. Check its current Zod adapter instructions before upgrading a Zod 3 application. This example deliberately tests plain Zod 4 with SvelteKit, not a Superforms integration.

Files, authentication and real submissions

A file upload needs a File-aware schema, a size policy and multipart/form-data; this text-only action rejects files. See the SvelteKit file upload guide for that workflow. For a protected form, authenticate the request and authorize the specific write on the server as well. The authentication sprint guide explains that boundary.

If an existing form loses data or fails only after deployment, I can help trace the request and validation path in a $149 consulting session. For a larger form or onboarding workflow, tell me what you need to ship.

Screenshots from the original 2022 version

The images below are retained from the original article. They show the earlier console/error display, not the refreshed Svelte 5 implementation above. The linked GitHub repository is also the original example, not a maintained copy of the new code.

Form date on the frontend
Form error notices