Reference: Form actions

Reference

Form actions

Any non-GET export from +page.server.ts is a form action. Actions run on the server, never reach the browser, and are dispatched by the form that submits them.

A single action

Export the HTTP method, or a function that Grimoire dispatches by name:

src/routes/login/+page.server.ts
import { redirect } from "@sigil-dev/grimoire";

export async function POST({ request }: LoadContext) {
	const form = await request.formData();
	const email = String(form.get("email") ?? "");

	await createUser(email);
	throw redirect("/dashboard");
}

Actions take the same context object as load, plus cookies — type it as LoadContext.

Named actions

Export differently-named functions and address them with ?/name:

src/routes/books/+page.server.ts
export async function create({ request }: LoadContext) { /* … */ }
export async function archive({ request }: LoadContext) { /* … */ }
<form method="POST" action="?/create">…</form>
<form method="POST" action="?/archive">…</form>

Asking for a name that isn't exported is a 404. These are never treated as actions: load, canMatch, csrf, loading, default. A GET or HEAD never dispatches an action at all, whatever the query says.

Context

FieldWhat it is
requestThe POST request. await request.formData() reads the body.
paramsDynamic segments, same as in load.
urlThe parsed URL, including ?/actionName.
localsFrom hooks and plugins.
fetchCookie-preserving fetch, for calling your own routes.
cookiesget, set and delete for response cookies.

Returning

Three outcomes, all by throwing or returning — and the distinction between returning and throwing matters:

// redirect
throw redirect("/books");

// validation failure: re-render the page with data
return fail(400, { email, message: "That address is already registered." });

// success
return { ok: true };

Return a plain object to succeed. Grimoire redirects to the referer, or to result.redirect if you set one:

return { ok: true, redirect: "/books?created=1" };

Return fail(status, data) to show the page again with your data as its form prop, and the given status. This is the validation path:

export const Page = ({ form }: { form?: { message: string } }) => (
	<form method="POST">
		{form?.message ? <p class="error">{form.message}</p> : null}
		…
	</form>
);

Throw redirect(location) to send the visitor somewhere with a 303. A raw Response thrown from an action is swallowed — throw the sentinel, not the response.

fail() only returns JSON when the form is enhanced (see below); on a plain POST it re-renders HTML, so the visitor keeps their input.

Cookies

export async function POST({ cookies }: LoadContext) {
	cookies.set("session", token, {
		httpOnly: true,
		secure: true,
		sameSite: "lax",
		maxAge: 60 * 60 * 24 * 30,
	});
	cookies.delete("old_session");
}

path defaults to /, which is almost always what you want — without it the browser scopes the cookie to the request's directory and a session set at /account/login never reaches /. Options: path, domain, maxAge, expires, httpOnly, secure, sameSite. Values are URL-encoded on the way out and decoded on the way back in, so get returns what you set.

CSRF

Every non-GET action is CSRF-checked: a _csrf cookie must be present and match a _csrf field in the form body, or the request is a 403. Grimoire injects both into every rendered form, so a plain <form method="POST"> is protected without you doing anything.

Export csrf = false to opt a single module out — only do that for endpoints that are not cookie-authenticated, like a public webhook. Opting out of a session-authenticated action removes the only thing stopping another site from posting as your user.

Progressive enhancement

A plain form post works with no JavaScript: the browser gets a 303, or a re-render on fail. To submit with fetch instead, attach the enhance directive so the page updates in place:

import { enhance } from "@sigil-dev/grimoire/client";

<form method="POST" use={[enhance]}>
	…
</form>

use takes an array of directives; each runs when the element mounts and returns a cleanup function. With no options it posts to the form's own action, resets the form on success, and navigates on redirect.

Options are the second array entry:

<form
	method="POST"
	use={[enhance, {
		action: "?/create",              // override the form's action
		reset: false,                    // keep the user's input on success
		onSuccess: (r) => console.log(r.data),
		onFail: (data) => showErrors(data),
		onError: (err) => showBanner(err.message),
	}]}
>
	…
</form>

For a named action, ?/create resolves against the current path, so it posts to the page you are on:

<form method="POST" use={[enhance, { action: "?/create" }]}>
	…
</form>

The outcome arrives as JSON and enhance applies it: a redirect navigates, a fail calls onFail, a success calls onSuccess. Your action code does not change — the same handler serves both, and the x-grimoire-form header is what tells them apart. enhance injects the CSRF token itself, so a client-rendered form is covered too.

Next

Edit this page