Files
crumbs/.agents/skills/sveltekit-data-flow/references/form-actions.md
T
Frederic CabassutandClaude Opus 4.6 02e6b57764 chore: add agent skills for Svelte 5, SvelteKit, TypeScript, and Drizzle
Install project-level skills via skills.sh CLI:
- svelte5-best-practices (runes, snippets, events, migration)
- sveltekit-structure (routing, layouts, error boundaries)
- sveltekit-data-flow (load functions, form actions, serialization)
- typescript-best-practices (type-first development, discriminated unions)
- drizzle-migrations (SQLite schema, query patterns)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:11:36 +01:00

5.7 KiB

Form Actions

Basic Pattern

Form actions live in +page.server.ts and handle form submissions:

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

export const actions: Actions = {
	default: async ({ request }) => {
		const data = await request.formData();
		const email = data.get('email');
		const password = data.get('password');

		if (!email) {
			return fail(400, { email, missing: true });
		}

		await login(email, password);
		throw redirect(303, '/dashboard');
	},
};
<!-- +page.svelte -->
<script>
	export let form; // Contains return value from action
</script>

<form method="POST">
	<input name="email" value={form?.email ?? ''} />
	{#if form?.missing}
		<p class="error">Email is required</p>
	{/if}

	<button>Login</button>
</form>

Named Actions

export const actions: Actions = {
	login: async ({ request }) => {
		// Handle login
	},

	register: async ({ request }) => {
		// Handle registration
	},
};
<form method="POST" action="?/login">...</form>
<form method="POST" action="?/register">...</form>

Return Values

Option 1: fail() - Show Error to User

import { fail } from '@sveltejs/kit';

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

		if (!isValid(data)) {
			return fail(400, {
				error: 'Invalid data',
				fields: Object.fromEntries(data), // Preserve input
			});
		}

		// Success - no return needed
	},
};

Option 2: redirect() - Navigate After Success

import { redirect } from '@sveltejs/kit';

export const actions = {
	default: async ({ request }) => {
		await processForm(request);
		throw redirect(303, '/success'); // MUST throw
	},
};

Option 3: error() - Fatal Error

import { error } from '@sveltejs/kit';

export const actions = {
	default: async ({ request }) => {
		const user = await getCurrentUser();

		if (!user) {
			throw error(401, 'Unauthorized'); // MUST throw
		}

		// Process...
	},
};

Progressive Enhancement

Form works without JavaScript:

<script>
	import { enhance } from '$app/forms';
</script>

<form method="POST" use:enhance>
	<!-- Works with or without JS -->
</form>

With custom handling:

<form
	method="POST"
	use:enhance={({ formData, cancel }) => {
		// Before submit
		formData.append('timestamp', Date.now().toString());

		return async ({ result, update }) => {
			// After response
			if (result.type === 'success') {
				await update(); // Update form prop
			}
		};
	}}
>
	...
</form>

Validation Pattern

import { fail } from '@sveltejs/kit';
import { z } from 'zod';

const schema = z.object({
	email: z.string().email(),
	password: z.string().min(8),
});

export const actions = {
	default: async ({ request }) => {
		const data = await request.formData();
		const rawData = {
			email: data.get('email'),
			password: data.get('password'),
		};

		const result = schema.safeParse(rawData);

		if (!result.success) {
			return fail(400, {
				errors: result.error.flatten().fieldErrors,
				data: rawData,
			});
		}

		// result.data is validated
		await createUser(result.data);
		throw redirect(303, '/welcome');
	},
};

Common Mistakes

❌ Not Throwing redirect/error

// WRONG
export const actions = {
	default: async () => {
		redirect(303, '/home'); // DOESN'T WORK - must throw
	},
};

// RIGHT
export const actions = {
	default: async () => {
		throw redirect(303, '/home');
	},
};

❌ Catching redirect Without Rethrowing

// WRONG
export const actions = {
	default: async () => {
		try {
			await doSomething();
			throw redirect(303, '/success');
		} catch (e) {
			console.error(e); // Catches redirect!
		}
	},
};

// RIGHT
export const actions = {
	default: async () => {
		try {
			await doSomething();
			throw redirect(303, '/success');
		} catch (e) {
			if (e instanceof Redirect) throw e; // Rethrow
			console.error(e);
		}
	},
};

❌ Returning non-serializable data

// WRONG
export const actions = {
	default: async () => {
		return { date: new Date() }; // Date is not serializable
	},
};

// RIGHT
export const actions = {
	default: async () => {
		return { date: new Date().toISOString() };
	},
};

File Upload

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

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

		const bytes = await file.arrayBuffer();
		const buffer = Buffer.from(bytes);

		await saveFile(buffer, file.name);
		throw redirect(303, '/uploads');
	},
};
<form method="POST" enctype="multipart/form-data">
	<input type="file" name="file" required />
	<button>Upload</button>
</form>

Form Prop in Page

<script>
	export let data; // From load function
	export let form; // From action return value
</script>

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

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

<form method="POST">
	<input name="email" value={form?.email ?? data.email ?? ''} />
	{#if form?.errors?.email}
		<p>{form.errors.email}</p>
	{/if}

	<button>Submit</button>
</form>

Key Rules

  1. ✅ Actions must be in +page.server.ts (not +page.ts)
  2. ✅ ALWAYS throw redirect() and error() (not return)
  3. ✅ Return fail() for validation errors
  4. ✅ Return only serializable data
  5. ✅ Don't catch redirects/errors without rethrowing
  6. ✅ Use enhance for progressive enhancement
  7. ✅ Access FormData with data.get('fieldName')