GitHub

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

ReturnResponseUse for
{ redirect: '/path' }303 See OtherSuccessful submissions — POST-redirect-GET means refresh never resubmits
{ errors, values, … }Page re-renders (200), value exposed as server.formValidation failures — show errors, echo values back into inputs
{ status: 422, errors, … }Same re-render with status 422Validation failures where the status code matters (API clients, tests)
undefinedPage 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 ?? '' })}
Escape user-submitted values before interpolating them into HTML — 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:

PieceWhere it livesWhat it does
Random idpulse_csrf cookie (HttpOnly, SameSite=Lax)Set on the first GET of the form page
TokenHidden _csrf input via ${server.csrf}HMAC-SHA256(secret, id) — embedded into the form server-side
VerificationOn every POST, before submit runsRecomputes 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>',
}
Never set 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

PropertyTypeNotes
submitasync (ctx) => { redirect } | object | voidHandles POST on the route. Guard runs first.
csrfbooleanDefault true. Set false only for self-authenticated endpoints.
server.csrfstring (view)Hidden _csrf input — required inside every POST form.
server.formobject | undefined (view)Return value of submit on a re-render; undefined on GET.
secretstring (createServer option)Stable HMAC secret for multi-instance deployments.