Published 12/5/2025 · 7 min read
Lesson 16: Form Actions
Forms are fundamental to web apps. SvelteKit handles them beautifully with progressive enhancement — forms work without JavaScript, and get enhanced when it’s available.
Basic Form Action
Define actions in +page.server.js:
// src/routes/contact/+page.server.js
export const actions = {
default: async ({ request }) => {
const formData = await request.formData();
const name = formData.get("name");
const email = formData.get("email");
const message = formData.get("message");
// Do something with the data
await sendEmail({ name, email, message });
return { success: true };
},
};
<!-- src/routes/contact/+page.svelte -->
<script>
export let form
</script>
{#if form?.success}
<p class="success">Message sent!</p>
{/if}
<form method="POST">
<input name="name" placeholder="Your name" required />
<input name="email" type="email" placeholder="Email" required />
<textarea name="message" placeholder="Message" required></textarea>
<button type="submit">Send</button>
</form>
Key points:
method="POST"— Form posts to the same pageexport let form— Receives the action’s return value- No JavaScript needed — this works with JS disabled
Named Actions
Multiple actions on one page:
// src/routes/todos/+page.server.js
export const actions = {
create: async ({ request }) => {
const data = await request.formData();
const text = data.get("text");
await db.createTodo(text);
return { created: true };
},
delete: async ({ request }) => {
const data = await request.formData();
const id = data.get("id");
await db.deleteTodo(id);
return { deleted: true };
},
toggle: async ({ request }) => {
const data = await request.formData();
const id = data.get("id");
await db.toggleTodo(id);
},
};
<!-- src/routes/todos/+page.svelte -->
<form method="POST" action="?/create">
<input name="text" placeholder="New todo" />
<button>Add</button>
</form>
{#each data.todos as todo}
<div class="todo">
<form method="POST" action="?/toggle">
<input type="hidden" name="id" value={todo.id} />
<button>{todo.done ? '✓' : '○'}</button>
</form>
<span class:done={todo.done}>{todo.text}</span>
<form method="POST" action="?/delete">
<input type="hidden" name="id" value={todo.id} />
<button>Delete</button>
</form>
</div>
{/each}
The action="?/actionName" targets a specific action.
Progressive Enhancement
Without JavaScript, forms do full page reloads. Add use:enhance for a better experience:
<script>
import { enhance } from '$app/forms'
</script>
<form method="POST" use:enhance>
<!-- form fields -->
</form>
Now the form:
- Submits without page reload
- Shows pending state
- Updates
formprop with the result - Works even if JavaScript fails to load
Custom Enhancement
Control the enhancement behavior:
<script>
import { enhance } from '$app/forms'
let loading = false
</script>
<form
method="POST"
use:enhance={() => {
loading = true
return async ({ result, update }) => {
loading = false
if (result.type === 'success') {
// Custom success handling
showToast('Saved!')
}
// Call update() to apply default behavior
await update()
}
}}
>
<button disabled={loading}>
{loading ? 'Saving...' : 'Save'}
</button>
</form>
Validation
Return errors from actions:
// src/routes/signup/+page.server.js
import { fail } from "@sveltejs/kit";
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
const email = data.get("email");
const password = data.get("password");
const errors = {};
if (!email?.includes("@")) {
errors.email = "Please enter a valid email";
}
if (password?.length < 8) {
errors.password = "Password must be at least 8 characters";
}
if (Object.keys(errors).length) {
return fail(400, {
errors,
email, // Return email so the field stays filled
});
}
// Create user...
await createUser(email, password);
return { success: true };
},
};
<!-- src/routes/signup/+page.svelte -->
<script>
import { enhance } from '$app/forms'
export let form
</script>
<form method="POST" use:enhance>
<div class="field">
<input
name="email"
type="email"
value={form?.email ?? ''}
placeholder="Email"
/>
{#if form?.errors?.email}
<span class="error">{form.errors.email}</span>
{/if}
</div>
<div class="field">
<input
name="password"
type="password"
placeholder="Password"
/>
{#if form?.errors?.password}
<span class="error">{form.errors.password}</span>
{/if}
</div>
<button>Sign Up</button>
</form>
{#if form?.success}
<p>Account created! Check your email.</p>
{/if}
fail() returns a 400 response but keeps the data accessible in form.
Redirects After Actions
// src/routes/login/+page.server.js
import { redirect, fail } from "@sveltejs/kit";
export const actions = {
default: async ({ request, cookies }) => {
const data = await request.formData();
const email = data.get("email");
const password = data.get("password");
const user = await authenticate(email, password);
if (!user) {
return fail(401, {
error: "Invalid credentials",
email,
});
}
// Set session cookie
cookies.set("session", user.sessionId, {
path: "/",
httpOnly: true,
sameSite: "strict",
maxAge: 60 * 60 * 24 * 7, // 1 week
});
// Redirect to dashboard
throw redirect(303, "/dashboard");
},
};
Use 303 for redirects after POST.
File Uploads
Handle file uploads:
<form method="POST" enctype="multipart/form-data" use:enhance>
<input type="file" name="avatar" accept="image/*" />
<button>Upload</button>
</form>
// +page.server.js
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
const file = data.get("avatar");
if (file.size > 5 * 1024 * 1024) {
return fail(400, { error: "File too large" });
}
const buffer = await file.arrayBuffer();
const filename = `${crypto.randomUUID()}.${file.name.split(".").pop()}`;
await saveFile(filename, buffer);
return { success: true, filename };
},
};
Comparing to Nuxt
Nuxt uses API routes for form handling:
<script setup>
async function handleSubmit() {
await $fetch("/api/contact", {
method: "POST",
body: { name, email, message },
});
}
</script>
SvelteKit’s approach is more integrated:
- Forms work without JavaScript
- Data and actions live together
- Built-in progressive enhancement
Practical Example: Complete Auth Form
// src/routes/auth/+page.server.js
import { fail, redirect } from "@sveltejs/kit";
import { db } from "$lib/server/database";
import { hash, verify } from "$lib/server/auth";
export const actions = {
login: async ({ request, cookies }) => {
const data = await request.formData();
const email = data.get("email")?.toString().trim();
const password = data.get("password")?.toString();
if (!email || !password) {
return fail(400, {
action: "login",
error: "Email and password required",
email,
});
}
const user = await db.getUserByEmail(email);
if (!user || !(await verify(password, user.passwordHash))) {
return fail(401, {
action: "login",
error: "Invalid email or password",
email,
});
}
const session = await db.createSession(user.id);
cookies.set("session", session.id, {
path: "/",
httpOnly: true,
sameSite: "strict",
secure: true,
maxAge: 60 * 60 * 24 * 7,
});
throw redirect(303, "/dashboard");
},
register: async ({ request }) => {
const data = await request.formData();
const email = data.get("email")?.toString().trim();
const password = data.get("password")?.toString();
const confirmPassword = data.get("confirmPassword")?.toString();
const errors = {};
if (!email?.includes("@")) {
errors.email = "Valid email required";
}
if (!password || password.length < 8) {
errors.password = "Password must be 8+ characters";
}
if (password !== confirmPassword) {
errors.confirmPassword = "Passwords do not match";
}
if (Object.keys(errors).length) {
return fail(400, { action: "register", errors, email });
}
const existing = await db.getUserByEmail(email);
if (existing) {
return fail(400, {
action: "register",
errors: { email: "Email already registered" },
email,
});
}
await db.createUser(email, await hash(password));
return { action: "register", success: true };
},
};
<!-- src/routes/auth/+page.svelte -->
<script>
import { enhance } from '$app/forms'
export let form
let mode = 'login'
</script>
<div class="auth-container">
<div class="tabs">
<button
class:active={mode === 'login'}
onclick={() => mode = 'login'}
>
Login
</button>
<button
class:active={mode === 'register'}
onclick={() => mode = 'register'}
>
Register
</button>
</div>
{#if mode === 'login'}
<form method="POST" action="?/login" use:enhance>
{#if form?.action === 'login' && form?.error}
<div class="error">{form.error}</div>
{/if}
<input
name="email"
type="email"
placeholder="Email"
value={form?.action === 'login' ? form?.email ?? '' : ''}
/>
<input
name="password"
type="password"
placeholder="Password"
/>
<button>Login</button>
</form>
{:else}
{#if form?.action === 'register' && form?.success}
<p class="success">Account created! Please login.</p>
{/if}
<form method="POST" action="?/register" use:enhance>
<input
name="email"
type="email"
placeholder="Email"
value={form?.action === 'register' ? form?.email ?? '' : ''}
/>
{#if form?.errors?.email}
<span class="field-error">{form.errors.email}</span>
{/if}
<input
name="password"
type="password"
placeholder="Password"
/>
{#if form?.errors?.password}
<span class="field-error">{form.errors.password}</span>
{/if}
<input
name="confirmPassword"
type="password"
placeholder="Confirm Password"
/>
{#if form?.errors?.confirmPassword}
<span class="field-error">{form.errors.confirmPassword}</span>
{/if}
<button>Register</button>
</form>
{/if}
</div>
Key Takeaways
- Form actions live in
+page.server.jswithexport const actions method="POST"for default action,action="?/name"for named actions- Forms work without JavaScript (progressive enhancement)
- Add
use:enhancefor no-reload submissions - Use
fail()to return validation errors - Use
redirect()after successful mutations - Return data from actions — it’s available in
formprop
Next: Lesson 17: API Routes
Related Articles
- x402 with SvelteKit: Full-Stack Example
Build a complete SvelteKit application with x402 payments - wallet connection, protected routes, and automatic payment handling.
- Interacting with Programs from Svelte
Build Svelte components that interact with Solana programs - token balances, transfers, and real-time updates.
- Signing Messages and Transactions in the Browser
Learn to sign messages for authentication and build transactions that users approve through their wallet.