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:
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:
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
| Field | What it is |
|---|---|
request | The POST request. await request.formData() reads the body. |
params | Dynamic segments, same as in load. |
url | The parsed URL, including ?/actionName. |
locals | From hooks and plugins. |
fetch | Cookie-preserving fetch, for calling your own routes. |
cookies | get, 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
- Hooks — reading and writing
locals - Handling errors — the error boundary