Forms
A spec with a submit handler accepts POST requests on its own route — the form works with JavaScript disabled. CSRF protection is automatic, and successful submissions follow POST-redirect-GET. Client-side actions become an enhancement, not a requirement.
Basic usage
Add a submit function to the spec and render a plain <form method="POST">. The route accepts POST automatically — no methods declaration needed.
export default {
route: '/contact',
submit: async (ctx) => {
const data = await ctx.formData()
if (!data?.email) return { errors: { email: 'Email is required' }, values: data ?? {} }
await sendMessage(data)
return { redirect: '/contact?sent=1' } // 303 POST-redirect-GET
},
view: (state, server) => `
<main id="main-content">
${server.form?.errors?.email ? `<p class="error">${escHtml(server.form.errors.email)}</p>` : ''}
<form method="POST">
${server.csrf}
${input({ label: 'Email', name: 'email', type: 'email', required: true,
value: server.form?.values?.email ?? '' })}
${button({ label: 'Send', type: 'submit' })}
</form>
</main>
`,
}
The flow: the browser POSTs the form, submit(ctx) runs server-side, and either redirects (success) or re-renders the page with the returned value available to the view as server.form (validation failure).
${server.csrf} must be inside every POST form. It renders the hidden CSRF token input — submissions without a valid token are rejected with 403 before submit runs.Return values
| Return | Response | Use for |
|---|---|---|
{ redirect: '/path' } | 303 See Other | Successful submissions — POST-redirect-GET means refresh never resubmits |
{ errors, values, … } | Page re-renders (200), value exposed as server.form | Validation failures — show errors, echo values back into inputs |
{ status: 422, errors, … } | Same re-render with status 422 | Validation failures where the status code matters (API clients, tests) |
undefined | Page re-renders (200), server.form is {} | Fire-and-forget side effects |
Always redirect after a successful mutation. Rendering a success message directly from the POST means a browser refresh resubmits the form. Redirect to a URL that shows the confirmation instead (e.g. /contact?sent=1).
server.form — errors and echoed values
On a re-render, whatever submit returned is available to the view as server.form. On a plain GET it is undefined — always use optional chaining.
// In submit — return errors AND the submitted values:
return { errors: { email: 'Invalid email' }, values: data }
// In the view — show the error, echo the value back so the form isn't wiped:
${server.form?.errors?.email ? `<p class="error">${escHtml(server.form.errors.email)}</p>` : ''}
${input({ name: 'email', label: 'Email', value: server.form?.values?.email ?? '' })}
server.form.values is raw user input. Component props like input({ value }) are auto-escaped; manual interpolation needs escHtml().CSRF protection
Every submit spec gets CSRF protection automatically — there is nothing to configure. Under the hood Pulse uses an HMAC double-submit pattern:
| Piece | Where it lives | What it does |
|---|---|---|
| Random id | pulse_csrf cookie (HttpOnly, SameSite=Lax) | Set on the first GET of the form page |
| Token | Hidden _csrf input via ${server.csrf} | HMAC-SHA256(secret, id) — embedded into the form server-side |
| Verification | On every POST, before submit runs | Recomputes the HMAC from the cookie and compares in constant time — mismatch is 403 |
An attacker on another origin can cause the cookie to be sent, but cannot read it or compute the HMAC — so they cannot forge the pair.
Multiple server instances
The HMAC secret defaults to a random value per server boot. Behind a load balancer, pin it so tokens issued by one instance validate on another:
await createServer(pages, {
secret: process.env.PULSE_SECRET, // stable across instances and restarts
})
Opting out
Set csrf: false on the spec to disable protection — only for endpoints with their own authentication, such as signed webhooks:
export default {
route: '/webhooks/stripe',
csrf: false, // Stripe signs its requests — verify the signature instead
submit: async (ctx) => {
verifyStripeSignature(ctx.headers['stripe-signature'], await ctx.text())
// ...
},
view: () => '<main id="main-content">ok</main>',
}
csrf: false to "fix" a 403. The 403 means the form is missing ${server.csrf} — add the field, don't remove the protection.Progressive enhancement
The same form can serve both worlds: method="POST" is the no-JS fallback, data-action is the hydrated path. With JavaScript enabled the runtime intercepts the submit and runs the async action (no page reload); without it the browser POSTs to submit.
<form method="POST" data-action="send">
${server.csrf}
${input({ label: 'Email', name: 'email', type: 'email', required: true })}
${button({ label: 'Send', type: 'submit' })}
</form>
Keep the two paths consistent: submit and the action's run should call the same underlying function so validation and side effects cannot drift apart.
Interaction with caching
Pages with submit are automatically excluded from the in-process page cache — the CSRF token is per-visitor, and a cached page would serve one visitor's token to everyone. POST responses are never cached.
Reference
| Property | Type | Notes |
|---|---|---|
submit | async (ctx) => { redirect } | object | void | Handles POST on the route. Guard runs first. |
csrf | boolean | Default true. Set false only for self-authenticated endpoints. |
server.csrf | string (view) | Hidden _csrf input — required inside every POST form. |
server.form | object | undefined (view) | Return value of submit on a re-render; undefined on GET. |
secret | string (createServer option) | Stable HMAC secret for multi-instance deployments. |