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>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.