stoneware

Documentation

Server actions

Form handling where CSRF verification is structural, not a decorator.

Any exported HTTP method handler under routes/api/ is a server action. By the time it runs, the framework has already verified the CSRF token.

routes/api/subscribe.tstsx
import type { ActionContext } from "stoneware";

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

  return Response.json({ ok: true });
}

Forms

Use the Form helper instead of a raw form element and the hidden token field is injected for you.

routes/index.tsxtsx
import { Form } from "stoneware";

<Form action="/api/subscribe">
  <input type="email" name="email" required />
  <button type="submit">Subscribe</button>
</Form>;

Verification happens in the request pipeline, before any handler is reached, on every non-GET request. It is not something a route opts into — a raw form does not silently skip protection, it simply fails. The token is checked against a clone of the request, so your handler still receives an unconsumed body.

PUT, PATCH and DELETE from a form

HTML forms can only issue GET and POST. That is a browser limitation with no workaround, so Form takes the method you meant, submits a POST, and carries the real method in a hidden _method field that the router unwraps before matching a handler.

routes/posts/[id].tsxtsx
<Form action={`/api/posts/${params.id}`} method="DELETE">
  <button type="submit">Delete</button>
</Form>;
routes/api/posts/[id].tsts
export async function DELETE({ params }: ActionContext) {
  await deletePost(params.id);
  return new Response(null, { status: 303, headers: { Location: "/posts" } });
}
The override is read only after CSRF verification has passed. Order matters: if _method were unwrapped first, a form field would be choosing which handler ran on a request that had not yet been proven to come from your own page.

Only PUT, PATCH and DELETE are accepted as overrides, and only on a POST carrying a form-encoded body. A _method field on anything else is ignored rather than obeyed. A request that already uses the real verb — a fetch() sending DELETE — needs none of this and never goes near the field.

Calling an action from an island

An island doing its own fetch() has no form to inject a token into, so pass one in as a prop. csrfToken() is callable during a server render and returns a token bound to nothing but your secret; send it in the x-csrf-token header.

routes/index.tsxtsx
import { csrfToken } from "stoneware";
import Subscribe from "../islands/Subscribe.tsx";

export default function Home() {
  return <Subscribe token={csrfToken()} />;
}
islands/Subscribe.tsxtsx
export default function Subscribe({ token }: { token: string }) {
  async function send() {
    await fetch("/api/subscribe", {
      method: "POST",
      headers: { "content-type": "application/json", "x-csrf-token": token },
      body: JSON.stringify({ email: email.value }),
    });
  }

  return <button onClick={send}>Subscribe</button>;
}

The header name is x-csrf-token and the form field is _csrf. Both are configurable — csrf.headerName and csrf.fieldName. If you change the field name and are building a form by hand rather than with Form, csrfFieldName() returns the configured value so the string is not written twice. Both functions read the render currently in progress, so they are callable from a route or a template and nowhere else.

Minting a token — through csrfToken() or through Form — marks the whole response private, no-store with no ETag. Reading csrfFieldName() does not; it only looks at config. A fresh token per render means the body genuinely changes every time, so there is nothing for a cache to revalidate against — which is why a page that mints a token stops being CDN-cacheable. See caching.

What a handler gets back

  • An unhandled method is 405 with an Allow header listing what the file does export, not a 404.
  • Verification runs against a clone of the request, so your handler still receives an unconsumed body.
  • A failing route answers a fetch() with JSON and a browser navigation with the error page, decided from the Accept header rather than from the path.
  • Return any Response you like. There is no convention about shape — Response.json(), a redirect, a 204, all fine.

Something wrong in the framework itself rather than the page? Open an issue on GitHub.