SvelteKit Form Actions: Handle Forms Without JavaScript
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:
- User fills out the form and clicks "Send Message"
- Browser sends a POST request with the form data
- SvelteKit calls the
defaultaction in+page.server.ts - The action processes the data and returns a result
- The page re-renders with the result available as the
formprop - 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:
- The form submission is intercepted (no full page reload)
- The form data is sent via
fetchinstead - The
formprop is updated with the response - 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
formprop gives your page access to action results use:enhanceadds 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
- Learn about SvelteKit load functions for data loading
- Explore SvelteKit routing for app structure
- Master Svelte 5 runes for reactivity
Source: Teta Engineering
This content was generated with AI from public sources. It represents analysis and commentary, not original journalism.