Skip to main content

Form actions

Edit this page on GitHub

A +page.server.js.ts file can export actions, which allow you to POST data to the server using the <form> element.

When using <form>, client-side JavaScript is optional, but you can easily progressively enhance your form interactions with JavaScript to provide the best user experience.

Default actions

In the simplest case, a page declares a default action:

src/routes/login/+page.server.js
ts
/** @type {import('./$types').Actions} */
export const actions = {
default: async (event) => {
// TODO log the user in
}
};
src/routes/login/+page.server.ts
ts
import type { Actions } from './$types';
export const actions = {
default: async (event) => {
// TODO log the user in
}
} satisfies Actions;

To invoke this action from the /login page, just add a <form> — no JavaScript needed:

src/routes/login/+page.svelte
<form method="POST">
    <label>
        Email
        <input name="email" type="email">
    </label>
    <label>
        Password
        <input name="password" type="password">
    </label>
    <button>Log in</button>
</form>

If someone were to click the button, the browser would send the form data via POST request to the server, running the default action.

Actions always use POST requests, since GET requests should never have side-effects.

We can also invoke the action from other pages (for example if there's a login widget in the nav in the root layout) by adding the action attribute, pointing to the page:

src/routes/+layout.svelte
<form method="POST" action="/login">
    <!-- content --></form>

Named actions

Instead of one default action, a page can have as many named actions as it needs:

src/routes/login/+page.server.js
/** @type {import('./$types').Actions} */
export const actions = {
	default: async (event) => {
	login: async (event) => {
        // TODO log the user in
    },
	register: async (event) => {
		// TODO register the user
	}
};

To invoke a named action, add a query parameter with the name prefixed by a / character:

src/routes/login/+page.svelte
<form method="POST" action="?/register">
src/routes/+layout.svelte
<form method="POST" action="/login?/register">

As well as the action attribute, we can use the formaction attribute on a button to POST the same form data to a different action than the parent <form>:

src/routes/login/+page.svelte
<form method="POST">
<form method="POST" action="?/login">
    <label>
        Email
        <input name="email" type="email">
    </label>
    <label>
        Password
        <input name="password" type="password">
    </label>
    <button>Log in</button>
	<button formaction="?/register">Register</button>
</form>

We can't have default actions next to named actions, because if you POST to a named action without a redirect, the query parameter is persisted in the URL, which means the next default POST would go through the named action from before.

Anatomy of an action

Each action receives a RequestEvent object, allowing you to read the data with request.formData(). After processing the request (for example, logging the user in by setting a cookie), the action can respond with data that will be available through the form property on the corresponding page and through $page.form app-wide until the next update.

src/routes/login/+page.server.js
ts
/** @type {import('./$types').PageServerLoad} */
export async function load({ cookies }) {
Cannot find name 'db'.2304Cannot find name 'db'.
const user = await db.getUserFromSession(cookies.get('sessionid'));
Cannot find name 'db'.2304Cannot find name 'db'.
return { user };
}
/** @type {import('./$types').Actions} */
export const actions = {
login: async ({ cookies, request }) => {
const data = await request.formData();
const email = data.get('email');
const password = data.get('password');
const user = await db.getUser(email);
cookies.set('sessionid', await db.createSession(user));
return { success: true };
},
register: async (event) => {
// TODO register the user
}
};
src/routes/login/+page.server.ts
ts
import type { PageServerLoad, Actions } from './$types';
Cannot find name 'db'.2304Cannot find name 'db'.
export const load = (async ({ cookies }) => {
Cannot find name 'db'.2304Cannot find name 'db'.
const user = await db.getUserFromSession(cookies.get('sessionid'));
return { user };
}) satisfies PageServerLoad;
export const actions = {
login: async ({ cookies, request }) => {
const data = await request.formData();
const email = data.get('email');
const password = data.get('password');
const user = await db.getUser(email);
cookies.set('sessionid', await db.createSession(user));
return { success: true };
},
register: async (event) => {
// TODO register the user
}
} satisfies Actions;
src/routes/login/+page.svelte
<script>
    /** @type {import('./$types').PageData} */    export let data;
    /** @type {import('./$types').ActionData} */    export let form;
</script>

{#if form?.success}
    <!-- this message is ephemeral; it exists because the page was rendered in           response to a form submission. it will vanish if the user reloads -->    <p>Successfully logged in! Welcome back, {data.user.name}</p>
{/if}
src/routes/login/+page.svelte
<script lang="ts">
    import type { PageData, ActionData } from './$types';

    export let data: PageData;

    export let form: ActionData;
</script>

{#if form?.success}
    <!-- this message is ephemeral; it exists because the page was rendered in           response to a form submission. it will vanish if the user reloads -->    <p>Successfully logged in! Welcome back, {data.user.name}</p>
{/if}

Validation errors

If the request couldn't be processed because of invalid data, you can return validation errors — along with the previously submitted form values — back to the user so that they can try again. The fail function lets you return an HTTP status code (typically 400 or 422, in the case of validation errors) along with the data. The status code is available through $page.status and the data through form:

src/routes/login/+page.server.js
import { fail } from '@sveltejs/kit';

/** @type {import('./$types').Actions} */
export const actions = {
    login: async ({ cookies, request }) => {
        const data = await request.formData();
        const email = data.get('email');
        const password = data.get('password');

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

        const user = await db.getUser(email);

		if (!user || user.password !== hash(password)) {
			return fail(400, { email, incorrect: true });
		}

        cookies.set('sessionid', await db.createSession(user));

        return { success: true };
    },
    register: async (event) => {
        // TODO register the user
    }
};

Note that as a precaution, we only return the email back to the page — not the password.

src/routes/login/+page.svelte
<form method="POST" action="?/login">
	{#if form?.missing}<p class="error">The email field is required</p>{/if}
	{#if form?.incorrect}<p class="error">Invalid credentials!</p>{/if}
    <label>
        Email
		<input name="email" type="email">
		<input name="email" type="email" value={form?.email ?? ''}>
    </label>
    <label>
        Password
        <input name="password" type="password">
    </label>
    <button>Log in</button>
    <button formaction="?/register">Register</button>
</form>

The returned data must be serializable as JSON. Beyond that, the structure is entirely up to you. For example, if you had multiple forms on the page, you could distinguish which <form> the returned form data referred to with an id property or similar.

Redirects

Redirects (and errors) work exactly the same as in load:

src/routes/login/+page.server.js
import { fail, redirect } from '@sveltejs/kit';

/** @type {import('./$types').Actions} */
export const actions = {
	login: async ({ cookies, request, url }) => {
        const data = await request.formData();
        const email = data.get('email');
        const password = data.get('password');

        const user = await db.getUser(email);
        if (!user) {
            return fail(400, { email, missing: true });
        }

        if (user.password !== hash(password)) {
            return fail(400, { email, incorrect: true });
        }

        cookies.set('sessionid', await db.createSession(user));

		if (url.searchParams.has('redirectTo')) {
			throw redirect(303, url.searchParams.get('redirectTo'));
		}

        return { success: true };
    },
    register: async (event) => {
        // TODO register the user
    }
};

Loading data

After an action runs, the page will be re-rendered (unless a redirect or an unexpected error occurs), with the action's return value available to the page as the form prop. This means that your page's load functions will run after the action completes.

Note that handle runs before the action is invoked, and does not re-run before the load functions. This means that if, for example, you use handle to populate event.locals based on a cookie, you must update event.locals when you set or delete the cookie in an action:

src/hooks.server.js
ts
/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
event.locals.user = await getUser(event.cookies.get('sessionid'));
return resolve(event);
}
src/hooks.server.ts
ts
import type { Handle } from '@sveltejs/kit';
export const handle = (async ({ event, resolve }) => {
event.locals.user = await getUser(event.cookies.get('sessionid'));
return resolve(event);
}) satisfies Handle;
src/routes/account/+page.server.js
ts
/** @type {import('./$types').PageServerLoad} */
export function load(event) {
return {
user: event.locals.user
};
}
/** @type {import('./$types').Actions} */
export const actions = {
logout: async (event) => {
event.cookies.delete('sessionid');
event.locals.user = null;
}
};
src/routes/account/+page.server.ts
ts
import type { PageServerLoad, Actions } from './$types';
export const load = ((event) => {
return {
user: event.locals.user
};
}) satisfies PageServerLoad;
export const actions = {
logout: async (event) => {
event.cookies.delete('sessionid');
event.locals.user = null;
}
} satisfies Actions;

Progressive enhancement

In the preceding sections we built a /login action that works without client-side JavaScript — not a fetch in sight. That's great, but when JavaScript is available we can progressively enhance our form interactions to provide a better user experience.

use:enhance

The easiest way to progressively enhance a form is to add the use:enhance action:

src/routes/login/+page.svelte
<script>
	import { enhance } from '$app/forms';

    /** @type {import('./$types').ActionData} */
    export let form;
</script>

<form method="POST" use:enhance>

Yes, it's a little confusing that the enhance action and <form action> are both called 'action'. These docs are action-packed. Sorry.

Without an argument, use:enhance will emulate the browser-native behaviour, just without the full-page reloads. It will:

  • update the form property, $page.form and $page.status on a successful or invalid response, but only if the action is on the same page you're submitting from. So for example if your form looks like <form action="/somewhere/else" ..>, form and $page will not be updated. This is because in the native form submission case you would be redirected to the page the action is on. If you want to have them updated either way, use applyAction
  • reset the <form> element and invalidate all data using invalidateAll on a successful response
  • call goto on a redirect response
  • render the nearest +error boundary if an error occurs
  • reset focus to the appropriate element

To customise the behaviour, you can provide a SubmitFunction that runs immediately before the form is submitted, and (optionally) returns a callback that runs with the ActionResult. Note that if you return a callback, the default behavior mentioned above is not triggered. To get it back, call update.

<form
    method="POST"
    use:enhance={({ form, data, action, cancel, submitter }) => {
        // `form` is the `<form>` element        // `data` is its `FormData` object        // `action` is the URL to which the form is posted        // `cancel()` will prevent the submission        // `submitter` is the `HTMLElement` that caused the form to be submitted
        return async ({ result, update }) => {
            // `result` is an `ActionResult` object            // `update` is a function which triggers the logic that would be triggered if this callback wasn't set        };
    }}
>

You can use these functions to show and hide loading UI, and so on.

applyAction

If you provide your own callbacks, you may need to reproduce part of the default use:enhance behaviour, such as showing the nearest +error boundary. Most of the time, calling update passed to the callback is enough. If you need more customization you can do so with applyAction:

src/routes/login/+page.svelte
<script>
	import { enhance, applyAction } from '$app/forms';

    /** @type {import('./$types').ActionData} */
    export let form;
</script>

<form
    method="POST"
    use:enhance={({ form, data, action, cancel }) => {
        // `form` is the `<form>` element
        // `data` is its `FormData` object
        // `action` is the URL to which the form is posted
        // `cancel()` will prevent the submission

        return async ({ result }) => {
            // `result` is an `ActionResult` object
			if (result.type === 'error') {
				await applyAction(result);
			}
        };
    }}
>

The behaviour of applyAction(result) depends on result.type:

  • success, failure — sets $page.status to result.status and updates form and $page.form to result.data (regardless of where you are submitting from, in contrast to update from enhance)
  • redirect — calls goto(result.location)
  • error — renders the nearest +error boundary with result.error

In all cases, focus will be reset.

Custom event listener

We can also implement progressive enhancement ourselves, without use:enhance, with a normal event listener on the <form>:

src/routes/login/+page.svelte
<script>
    import { invalidateAll, goto } from '$app/navigation';
    import { applyAction, deserialize } from '$app/forms';
    /** @type {import('./$types').ActionData} */    export let form;
    /** @type {any} */    let error;

    async function handleSubmit(event) {
        const data = new FormData(this);

        const response = await fetch(this.action, {
            method: 'POST',
            body: data
        });
        /** @type {import('@sveltejs/kit').ActionResult} */        const result = deserialize(await response.text());

        if (result.type === 'success') {
            // re-run all `load` functions, following the successful update            await invalidateAll();
        }

        applyAction(result);
    }
</script>

<form method="POST" on:submit|preventDefault={handleSubmit}>
    <!-- content --></form>
src/routes/login/+page.svelte
<script lang="ts">
    import { invalidateAll, goto } from '$app/navigation';
    import { applyAction, deserialize } from '$app/forms';

    import type { ActionData } from './$types';
    import type { ActionResult } from '@sveltejs/kit';

    export let form: ActionData;

    let error: any;

    async function handleSubmit(event) {
        const data = new FormData(this);

        const response = await fetch(this.action, {
            method: 'POST',
            body: data
        });

        const result: ActionResult = deserialize(await response.text());

        if (result.type === 'success') {
            // re-run all `load` functions, following the successful update            await invalidateAll();
        }

        applyAction(result);
    }
</script>

<form method="POST" on:submit|preventDefault={handleSubmit}>
    <!-- content --></form>

Note that you need to deserialize the response before processing it further using the corresponding method from $app/forms. JSON.parse() isn't enough because form actions - like load functions - also support returning Date or BigInt objects.

If you have a +server.js.ts alongside your +page.server.js.ts, fetch requests will be routed there by default. To POST to an action in +page.server.js.ts instead, use the custom x-sveltekit-action header:

const response = await fetch(this.action, {
    method: 'POST',
    body: data,
	headers: {
		'x-sveltekit-action': 'true'
	}
});

Alternatives

Form actions are the preferred way to send data to the server, since they can be progressively enhanced, but you can also use +server.js.ts files to expose (for example) a JSON API. Here's how such an interaction could look like:

send-message/+page.svelte
<script>
    function rerun() {
        fetch('/api/ci', {
            method: 'POST'
        });
    }
</script>

<button on:click={rerun}>Rerun CI</button>
api/ci/+server.js
ts
/** @type {import('./$types').RequestHandler} */
export function POST() {
// do something
}
api/ci/+server.ts
ts
import type { RequestHandler } from './$types';
export const POST = (() => {
// do something
}) satisfies RequestHandler;

GET vs POST

As we've seen, to invoke a form action you must use method="POST".

Some forms don't need to POST data to the server — search inputs, for example. For these you can use method="GET" (or, equivalently, no method at all), and SvelteKit will treat them like <a> elements, using the client-side router instead of a full page navigation:

<form action="/search">
    <label>
        Search
        <input name="q">
    </label>
</form>

Submitting this form will navigate to /search?q=... and invoke your load function but will not invoke an action. As with <a> elements, you can set the data-sveltekit-reload, data-sveltekit-replacestate, data-sveltekit-keepfocus and data-sveltekit-noscroll attributes on the <form> to control the router's behaviour.

Further reading