Form Actions
React forms are almost always handled with JavaScript: controlled inputs feed state into a fetch call on submit. SvelteKit takes a different approach — form actions are server-side handlers that work with native HTML form submission, so they function even without JavaScript. Adding use:enhance then layers in the fetch-based experience progressively.
Run this in a SvelteKit project (
npx sv create).
Basic form handler
Section titled “Basic form handler”React requires you to wire up controlled inputs, prevent the default submit, and fire a fetch manually. SvelteKit lets you export an actions object from +page.server.js and point the form’s action attribute at it.
// React — controlled form with fetchimport { useState } from 'react';
export default function CreatePost() { const [title, setTitle] = useState(''); const [error, setError] = useState(null);
async function handleSubmit(e) { e.preventDefault(); const res = await fetch('/api/posts', { method: 'POST', body: JSON.stringify({ title }), headers: { 'Content-Type': 'application/json' }, }); if (!res.ok) { const { message } = await res.json(); setError(message); } else { setTitle(''); } }
return ( <form onSubmit={handleSubmit}> {error && <p className="error">{error}</p>} <input value={title} onChange={e => setTitle(e.target.value)} placeholder="Post title" /> <button type="submit">Create</button> </form> );}<!-- +page.svelte --><script> let { form } = $props();</script>
{#if form?.error} <p class="error">{form.error}</p>{/if}
<form method="POST" action="?/create"> <input name="title" placeholder="Post title" /> <button type="submit">Create</button></form>
<!-- +page.server.js --><!--import { fail } from '@sveltejs/kit';
export const actions = { create: async ({ request }) => { const data = await request.formData(); const title = data.get('title');
if (!title) { return fail(400, { error: 'Title is required' }); }
await db.post.create({ data: { title } }); },};-->The form prop is automatically populated with whatever the action returns — including validation errors from fail(). Without JavaScript the page reloads; with JS it works the same way.
Progressive enhancement with use:enhance
Section titled “Progressive enhancement with use:enhance”React forms require JavaScript to do anything useful. SvelteKit’s use:enhance upgrades a native form to use fetch — giving you a SPA feel while keeping the no-JS fallback.
// React — onSubmit is the only way to interceptimport { useState } from 'react';
export default function ContactForm() { const [status, setStatus] = useState('idle');
async function handleSubmit(e) { e.preventDefault(); setStatus('submitting'); const formData = new FormData(e.currentTarget); const res = await fetch('/api/contact', { method: 'POST', body: formData, }); setStatus(res.ok ? 'done' : 'error'); }
return ( <form onSubmit={handleSubmit}> <input name="email" type="email" placeholder="Email" /> <button type="submit" disabled={status === 'submitting'}> {status === 'submitting' ? 'Sending…' : 'Send'} </button> {status === 'done' && <p>Sent!</p>} </form> );}<!-- +page.svelte --><script> import { enhance } from '$app/forms'; let { form } = $props(); let submitting = $state(false);</script>
<form method="POST" action="?/contact" use:enhance={() => { submitting = true; return async ({ update }) => { submitting = false; await update(); }; }}> <input name="email" type="email" placeholder="Email" /> <button type="submit" disabled={submitting}> {submitting ? 'Sending…' : 'Send'} </button> {#if form?.success} <p>Sent!</p> {/if}</form>use:enhance intercepts submission, fires the action via fetch, and updates the form prop with the returned data — all without a full page reload. Without JavaScript the form still submits and works normally.
Named actions in +page.server.js
Section titled “Named actions in +page.server.js”A single page can have multiple form actions. Name them in the actions export and reference them with ?/actionName in the form’s action attribute.
import { fail, redirect } from '@sveltejs/kit';
export const actions = { // default action — triggered by action="" or no action attribute default: async ({ request }) => { const data = await request.formData(); console.log(data.get('query')); },
// named action — triggered by action="?/create" create: async ({ request, locals }) => { const data = await request.formData(); const title = String(data.get('title') ?? '').trim();
if (title.length < 3) { return fail(400, { error: 'Title must be at least 3 characters', title }); }
await locals.db.post.create({ data: { title } }); redirect(303, '/posts'); },
// another named action — triggered by action="?/delete" delete: async ({ request, locals }) => { const data = await request.formData(); const id = data.get('id'); await locals.db.post.delete({ where: { id } }); },};Point each form at its action:
<!-- create form --><form method="POST" action="?/create"> <input name="title" /> <button>Create</button></form>
<!-- delete form --><form method="POST" action="?/delete"> <input type="hidden" name="id" value={post.id} /> <button>Delete</button></form>Validation errors and field repopulation
Section titled “Validation errors and field repopulation”Use fail(status, data) to return validation errors together with the submitted values, so the form can redisplay them without extra code.
import { fail } from '@sveltejs/kit';
export const actions = { default: async ({ request }) => { const data = await request.formData(); const email = data.get('email');
if (!email || !email.includes('@')) { return fail(422, { email, errors: { email: 'Please enter a valid email' }, }); }
await subscribe(email); return { success: true }; },};<script> let { form } = $props();</script>
<form method="POST"> <input name="email" type="email" value={form?.email ?? ''} /> {#if form?.errors?.email} <p class="error">{form.errors.email}</p> {/if} <button>Subscribe</button> {#if form?.success} <p>Subscribed!</p> {/if}</form>SvelteKit repopulates form fields automatically through the form prop when validation fails — you don’t have to write that plumbing yourself.
Key points
Section titled “Key points”- Form actions are defined in
+page.server.jsas an exportedactionsobject — each key is an action name. - Default action:
export const actions = { default: async ({ request }) => { ... } }— no?/nameneeded. - Named actions use
?/actionNamein the form’sactionattribute. use:enhancefrom$app/formsupgrades forms to usefetchwithout removing the no-JS fallback.- Return
fail(statusCode, data)for validation errors — the data lands in theformprop. - Data returned from an action is available in the page via
let { form } = $props(). - Unlike React, the form works with zero JavaScript — progressive enhancement is built in.