teta.so
SvelteKitFormsProgressive EnhancementTutorial

SvelteKit Form Actions: Handle Forms Without JavaScript

AI Generated

Introduction

Forms are fundamental to web applications, yet many modern frameworks make them unnecessarily complicated. SvelteKit takes a different approach with form actions — a server-side form handling system that works without any client-side JavaScript, while still allowing you to progressively enhance the experience.

In this tutorial, you will learn how to build forms that submit to the server, validate data, handle errors, and provide an excellent user experience with progressive enhancement.

Why Form Actions?

Traditional SPAs handle forms entirely in JavaScript: prevent the default submission, serialize the form data, send a fetch request, handle the response. This means your form is broken if JavaScript fails to load or has an error.

SvelteKit form actions work with the platform. A <form> element with method="POST" sends data to the server — no JavaScript required. SvelteKit then enhances this behavior with client-side features when JavaScript is available.

Your First Form Action

Let us build a simple contact form. First, create the page at src/routes/contact/+page.svelte:

<script lang="ts">
  import type { ActionData } from './$types';

  let { form }: { form: ActionData } = $props();
</script>

<h1>Contact Us</h1>

{#if form?.success}
  <div class="success">
    <p>Thank you for your message! We will get back to you soon.</p>
  </div>
{/if}

{#if form?.error}
  <div class="error">
    <p>{form.error}</p>
  </div>
{/if}

<form method="POST">
  <label>
    Name
    <input
      type="text"
      name="name"
      value={form?.name ?? ''}
      required
    />
  </label>

  <label>
    Email
    <input
      type="email"
      name="email"
      value={form?.email ?? ''}
      required
    />
  </label>

  <label>
    Message
    <textarea name="message" rows="5" required>{form?.message ?? ''}</textarea>
  </label>

  <button type="submit">Send Message</button>
</form>

Now create the server-side action at src/routes/contact/+page.server.ts:

import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';

export const actions: Actions = {
  default: async ({ request }) => {
    const formData = await request.formData();
    const name = formData.get('name') as string;
    const email = formData.get('email') as string;
    const message = formData.get('message') as string;

    // Validation
    if (!name || name.length < 2) {
      return fail(400, {
        error: 'Name must be at least 2 characters.',
        name,
        email,
        message
      });
    }

    if (!email || !email.includes('@')) {
      return fail(400, {
        error: 'Please enter a valid email address.',
        name,
        email,
        message
      });
    }

    if (!message || message.length < 10) {
      return fail(400, {
        error: 'Message must be at least 10 characters.',
        name,
        email,
        message
      });
    }

    // Process the form (send email, save to database, etc.)
    console.log('Contact form submitted:', { name, email, message });

    return { success: true };
  }
};

This form works entirely without JavaScript. When the user submits the form, the browser sends a POST request, the server processes it, and the page re-renders with the result.

Understanding the Flow

Here is what happens step by step:

  1. User fills out the form and clicks "Send Message"
  2. Browser sends a POST request with the form data
  3. SvelteKit calls the default action in +page.server.ts
  4. The action processes the data and returns a result
  5. The page re-renders with the result available as the form prop
  6. If validation failed, the form fields are repopulated with the submitted values

The fail() function from @sveltejs/kit returns the data with an HTTP error status code, which tells SvelteKit the action did not succeed. The form prop in the page component receives whatever data you return.

Named Actions

When a page has multiple forms (like login and register), use named actions:

// src/routes/auth/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';

export const actions: Actions = {
  login: async ({ request, cookies }) => {
    const formData = await request.formData();
    const email = formData.get('email') as string;
    const password = formData.get('password') as string;

    if (!email || !password) {
      return fail(400, {
        error: 'Email and password are required.',
        email
      });
    }

    // Authenticate the user
    const user = await authenticateUser(email, password);

    if (!user) {
      return fail(401, {
        error: 'Invalid email or password.',
        email
      });
    }

    // Set session cookie
    cookies.set('session', user.sessionToken, {
      path: '/',
      httpOnly: true,
      sameSite: 'lax',
      secure: true,
      maxAge: 60 * 60 * 24 * 30 // 30 days
    });

    redirect(303, '/dashboard');
  },

  register: async ({ request }) => {
    const formData = await request.formData();
    const email = formData.get('email') as string;
    const password = formData.get('password') as string;
    const confirmPassword = formData.get('confirmPassword') as string;

    if (password !== confirmPassword) {
      return fail(400, {
        error: 'Passwords do not match.',
        email
      });
    }

    if (password.length < 8) {
      return fail(400, {
        error: 'Password must be at least 8 characters.',
        email
      });
    }

    // Create the user
    await createUser(email, password);

    return { success: true, message: 'Account created! Please log in.' };
  }
};

In your page, each form targets a specific action using the action attribute:

<!-- src/routes/auth/+page.svelte -->
<script lang="ts">
  import type { ActionData } from './$types';

  let { form }: { form: ActionData } = $props();
</script>

<div class="auth-container">
  <div class="form-card">
    <h2>Login</h2>
    <form method="POST" action="?/login">
      <input type="email" name="email" placeholder="Email" required />
      <input type="password" name="password" placeholder="Password" required />
      <button type="submit">Log In</button>
    </form>
  </div>

  <div class="form-card">
    <h2>Register</h2>
    <form method="POST" action="?/register">
      <input type="email" name="email" placeholder="Email" required />
      <input type="password" name="password" placeholder="Password" required />
      <input type="password" name="confirmPassword" placeholder="Confirm Password" required />
      <button type="submit">Create Account</button>
    </form>
  </div>

  {#if form?.error}
    <div class="error">{form.error}</div>
  {/if}

  {#if form?.success}
    <div class="success">{form.message}</div>
  {/if}
</div>

The action="?/login" and action="?/register" syntax tells SvelteKit which named action to call. The ?/ prefix is SvelteKit's way of pointing to an action on the current page.

Progressive Enhancement with use:enhance

While form actions work without JavaScript, you can enhance the experience with SvelteKit's use:enhance directive. It intercepts the form submission and handles it with fetch, providing a smoother experience:

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

  let { form }: { form: ActionData } = $props();
  let submitting = $state(false);
</script>

<form
  method="POST"
  use:enhance={() => {
    submitting = true;

    return async ({ update }) => {
      await update();
      submitting = false;
    };
  }}
>
  <label>
    Name
    <input type="text" name="name" value={form?.name ?? ''} required />
  </label>

  <label>
    Email
    <input type="email" name="email" value={form?.email ?? ''} required />
  </label>

  <label>
    Message
    <textarea name="message" rows="5" required>{form?.message ?? ''}</textarea>
  </label>

  <button type="submit" disabled={submitting}>
    {submitting ? 'Sending...' : 'Send Message'}
  </button>
</form>

What use:enhance Does

When you add use:enhance to a form:

  1. The form submission is intercepted (no full page reload)
  2. The form data is sent via fetch instead
  3. The form prop is updated with the response
  4. The page is not fully re-rendered — only the reactive parts update

This gives you the best of both worlds: the form works without JavaScript (graceful degradation), and when JavaScript is available, the experience is smoother (progressive enhancement).

Custom Enhancement Logic

The use:enhance callback receives a cancel function and returns an async callback for handling the result:

<form
  method="POST"
  use:enhance={({ formData, cancel }) => {
    // Runs before submission

    // You can modify the form data
    formData.set('timestamp', new Date().toISOString());

    // You can cancel the submission
    const name = formData.get('name');
    if (!name) {
      alert('Please enter your name');
      cancel();
      return;
    }

    return async ({ result, update }) => {
      // Runs after submission

      if (result.type === 'success') {
        // Custom success handling
        alert('Form submitted!');
      }

      // Call update() to apply the default behavior
      await update();
    };
  }}
>
  <!-- form fields -->
</form>

Resetting the Form

By default, use:enhance resets the form after a successful submission. You can control this:

<form
  method="POST"
  use:enhance={() => {
    return async ({ update }) => {
      // Pass { reset: false } to keep form values after success
      await update({ reset: false });
    };
  }}
>
  <!-- form fields -->
</form>

Validation Patterns

Server-Side Validation with Zod

For robust validation, use a schema validation library like Zod:

// src/routes/contact/+page.server.ts
import { fail } from '@sveltejs/kit';
import { z } from 'zod';
import type { Actions } from './$types';

const contactSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.string().email('Invalid email address'),
  message: z.string().min(10, 'Message must be at least 10 characters')
});

export const actions: Actions = {
  default: async ({ request }) => {
    const formData = await request.formData();
    const data = Object.fromEntries(formData);

    const result = contactSchema.safeParse(data);

    if (!result.success) {
      const errors = result.error.flatten().fieldErrors;
      return fail(400, {
        errors,
        data: data as Record<string, string>
      });
    }

    // Process validated data
    await sendContactEmail(result.data);

    return { success: true };
  }
};

Display field-level errors in the page:

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

  let { form }: { form: ActionData } = $props();
</script>

<form method="POST" use:enhance>
  <label>
    Name
    <input
      type="text"
      name="name"
      value={form?.data?.name ?? ''}
    />
    {#if form?.errors?.name}
      <span class="field-error">{form.errors.name[0]}</span>
    {/if}
  </label>

  <label>
    Email
    <input
      type="email"
      name="email"
      value={form?.data?.email ?? ''}
    />
    {#if form?.errors?.email}
      <span class="field-error">{form.errors.email[0]}</span>
    {/if}
  </label>

  <label>
    Message
    <textarea name="message" rows="5">{form?.data?.message ?? ''}</textarea>
    {#if form?.errors?.message}
      <span class="field-error">{form.errors.message[0]}</span>
    {/if}
  </label>

  <button type="submit">Send</button>
</form>

<style>
  .field-error {
    color: #e53e3e;
    font-size: 0.875rem;
    margin-top: 0.25rem;
  }
</style>

File Uploads

Form actions handle file uploads seamlessly:

// src/routes/upload/+page.server.ts
import { fail } from '@sveltejs/kit';
import { writeFile } from 'fs/promises';
import path from 'path';
import type { Actions } from './$types';

export const actions: Actions = {
  default: async ({ request }) => {
    const formData = await request.formData();
    const file = formData.get('avatar') as File;

    if (!file || file.size === 0) {
      return fail(400, { error: 'Please select a file' });
    }

    // Validate file type
    const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
    if (!allowedTypes.includes(file.type)) {
      return fail(400, { error: 'Only JPEG, PNG, and WebP images are allowed' });
    }

    // Validate file size (max 5MB)
    if (file.size > 5 * 1024 * 1024) {
      return fail(400, { error: 'File must be less than 5MB' });
    }

    // Save the file
    const buffer = Buffer.from(await file.arrayBuffer());
    const filename = `${Date.now()}-${file.name}`;
    await writeFile(path.join('static', 'uploads', filename), buffer);

    return { success: true, filename };
  }
};
<!-- src/routes/upload/+page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';
  import type { ActionData } from './$types';

  let { form }: { form: ActionData } = $props();
</script>

<form method="POST" enctype="multipart/form-data" use:enhance>
  <label>
    Avatar
    <input type="file" name="avatar" accept="image/*" />
  </label>

  <button type="submit">Upload</button>

  {#if form?.error}
    <p class="error">{form.error}</p>
  {/if}

  {#if form?.success}
    <p class="success">Uploaded: {form.filename}</p>
  {/if}
</form>

Note the enctype="multipart/form-data" attribute, which is required for file uploads.

Summary

SvelteKit form actions give you a robust, server-first approach to handling forms:

  • Default actions handle simple single-form pages
  • Named actions support multiple forms per page with action="?/name"
  • The fail() helper returns validation errors with proper HTTP status codes
  • The form prop gives your page access to action results
  • use:enhance adds progressive enhancement without breaking no-JS support
  • Validation works with any library (Zod, Yup, etc.) on the server
  • File uploads work naturally with enctype="multipart/form-data"

The beauty of this system is that it builds on web platform fundamentals. Your forms work without JavaScript, and use:enhance simply makes them better when JavaScript is available.

Next Steps

Source: Teta Engineering

This content was generated with AI from public sources. It represents analysis and commentary, not original journalism.

Build your next app with AI

Try Teta — create sites and apps with AI agents.

Get started free