Ajo Kit

Actions

Handle writes with route actions, the action() helper and validation.

Loaders read; actions write. An action is a server function exported from a route’s handler.ts and called with a POST to the page’s own URL. In the browser, the action() helper sends forms and values to it, tracks the request, and brings the page up to date when it succeeds.

Define actions

Export an actions object. Each action receives the request, the response and an action context, and returns the result for the browser.

src/notes/handler.ts
import { Missing, type ActionContext, type Request, type Response } from 'ajo-kit'
import { maxLength, minLength, number, object, parse, pipe, string, trim } from 'ajo-kit/validate'
import { db } from '../database'

const Note = object({
  text: pipe(string(), trim(), minLength(1, 'Write a note'), maxLength(160, 'Use at most 160 characters')),
})

const Identity = object({ id: number() })

export async function page(req: Request) {
  req.track?.('notes')
  const notes = await db().selectFrom('notes').select(['id', 'text']).orderBy('id', 'desc').limit(50).execute()
  return { notes }
}

export const actions = {
  async add(req: Request, _res: Response, action: ActionContext) {
    const { text } = parse(Note, req.body)
    await db().insertInto('notes').values({ text }).execute()
    action.emit('notes')
    return { message: 'Note added' }
  },

  async remove(req: Request, _res: Response, action: ActionContext) {
    const { id } = parse(Identity, req.body)
    const removed = await db().deleteFrom('notes').where('id', '=', id).returning('id').executeTakeFirst()
    if (!removed) throw new Missing('Note not found')
    action.emit('notes')
    return { message: 'Note removed' }
  },
}

Kit accepts a POST at every page URL. The action name comes from the query string: POST /notes?/add runs add, and a POST without a ?/name runs the action named default. Kit looks for the name in the page’s own handler.ts first and then in each ancestor directory, so an action defined beside a layout, such as a sign-out action in src/(app)/handler.ts, can be called from every page below it. An unknown name fails with 400.

Middleware from wares.ts runs before the action, as it does for page loads. Kit parses a JSON body into req.body first, up to 100 KiB: a larger body fails with 413 and malformed JSON with 422. Other content types are not parsed.

The action context

The third argument is an ActionContext. Its one method, emit(topic), takes a topic or an array of topics. It advances those topics, so live routes that track them refresh, and records them in this action’s response, so the browser that made the request refreshes too. Emit after the write has committed. Loaders, API handlers and background work use emit() from ajo-kit/server instead, which broadcasts without touching any action response. Live updates covers topics in depth.

Results and redirects

Return a JSON-compatible object; the browser receives it as the action’s data, with topics and versions added when the action emitted. Returning nothing sends { "ok": true }. Return { redirect: '/notes' } to send the browser to another route; the rest of the object is then dropped.

The second argument is the response. Use it for headers such as cookies, as ajo-kit-auth’s cookie.write(res, token) does after a sign-in. Kit writes the body.

Errors and validation

Throw any of the errors described in Loaders. The status and message reach the browser, and messages of 500 and above are masked in production.

For input, parse(schema, data) from ajo-kit/validate runs a Valibot schema and returns the typed output, or throws Invalid, a 400 that carries field messages:

  • fields maps each field name to its messages; issues that belong to no field go under _form.
  • The message is the first form-level message, else the first field message, else “Validation failed”.

The module re-exports the Valibot functions a form usually needs: object, string, number, boolean, array, optional, literal, unknown, pipe, trim, toLowerCase, transform, email, minLength, maxLength, forward and partialCheck, with the types GenericSchema and InferOutput. A check across fields can report on one of them:

TypeScript
import { forward, minLength, object, partialCheck, pipe, string } from 'ajo-kit/validate'

const Password = pipe(
  object({ password: pipe(string(), minLength(12, 'Use at least 12 characters')), confirm: string() }),
  forward(
    partialCheck([['password'], ['confirm']], input => input.password === input.confirm, 'Passwords must match'),
    ['confirm'],
  ),
)

For a rule a schema cannot express, throw Invalid yourself:

TypeScript
import { Invalid } from 'ajo-kit'

throw new Invalid({ title: ['Choose another title'] }, 'Choose another title')

The action() helper

action(name?, init?) from ajo-kit/client returns a live object for one action. Call it once, before the render loop of a stateful component. It re-renders that component whenever its state changes, so it throws outside one; inside the loop it would start over on every render. Any stateful component on the route can use it, not only the page.

src/notes/page.tsx
import type { Stateful } from 'ajo'
import type { PageArgs } from 'ajo-kit'
import { action } from 'ajo-kit/client'

type Data = { notes: { id: number; text: string }[] }

const Notes: Stateful<PageArgs<Data>> = function* () {

  const add = action<{ message: string }>('add')
  const remove = action<{ message: string }>('remove')

  for (const { data } of this) {
    const invalid = !!add.error?.fields?.text

    yield (
      <>
        <form method="post" action="?/add" set:onsubmit={add.submit}>
          <label for="note">New note</label>
          <input
            id="note" name="text" maxlength={160} required disabled={add.loading}
            aria-invalid={invalid ? 'true' : undefined} aria-describedby={add.error ? 'note-error' : undefined}
          />
          <button disabled={add.loading}>{add.loading ? 'Adding…' : 'Add'}</button>
          {add.error && <p id="note-error" role="alert">{add.error.message}</p>}
        </form>
        <ul>
          {data?.notes.map(note => (
            <li key={note.id}>
              {note.text}
              <button type="button" disabled={remove.loading} set:onclick={() => remove.invoke({ id: note.id })}>
                Delete
              </button>
            </li>
          ))}
        </ul>
        {remove.error && <p role="alert">{remove.error.message}</p>}
      </>
    )
  }
}

export default Notes
MemberDescription
loadingTrue while a request is in flight.
dataThe response body of the last successful call.
errorThe last failure as an Issue: { status, message, fields? }. A network failure reports status 500.
submit(event)A submit handler. It prevents the browser’s submission, sends the form’s fields, and resets the form when the call ends without an error.
invoke(body?)Sends body as JSON. Resolves with the data, or undefined after an error, an abort or a redirect.
reset()Aborts any request in flight and clears loading, data and error.

The helper posts to ?/name on the current URL, or to the current URL itself when no name is given, with the page’s cookies and JSON headers. A new call aborts the previous one from the same helper, and unmounting the component aborts it too. init is spread into the fetch options and its signal is combined with the helper’s own; a headers entry there replaces the JSON headers, so repeat them if you add your own.

How submit() reads a form

submit() reads the form through FormData and sends a JSON object of string values keyed by field name. A name used by more than one control, and a <select multiple>, becomes an array even when only one value is present; that includes radio groups, whose value arrives as a one-element array. File inputs are skipped, unchecked checkboxes are absent, and the button that submitted the form is not included.

After a successful action

When a call succeeds, the helper:

  1. invalidates the client route cache. Cached routes that track an emitted topic, or no topic at all, are dropped; when the action emitted nothing, the whole cache is cleared;
  2. navigates to the target if the response is a redirect, and stops there;
  3. otherwise stores the body in data and dispatches an ajo:action event on window whose detail is that body.

Kit’s router listens for that event. When the emitted topics include one the active route tracks, it gives an open live connection a moment to deliver the update and otherwise fetches the route again. An action that changes what the current page shows should therefore emit a topic that the page’s loader tracks. Your own components can listen too:

src/status.tsx
import type { Stateful } from 'ajo'

/** Announces the message returned by any successful action on the page. */
const Status: Stateful = function* () {

  let message = ''

  if (typeof document != 'undefined') addEventListener('ajo:action', event => {
    const detail = (event as CustomEvent<{ message?: string }>).detail
    if (detail.message) this.next(() => message = detail.message!)
  }, { signal: this.signal })

  while (true) yield <p role="status">{message}</p>
}

export default Status

Forms without JavaScript

Give each form method="post" and an action attribute of ?/name, as above, so it describes the same request in markup. Once the client has started, submit() takes over and sends JSON. Before that, or without JavaScript, the browser submits the form itself: Kit runs the same action and answers with a 302, to the returned redirect or back to the page’s path, and the browser loads the page again with fresh data. A form without an action attribute posts to the current URL and runs default.

Two things differ on that path. Kit parses only JSON bodies, so a native submission arrives with an empty req.body. And a thrown error renders the error page with its status instead of messages beside the fields. An action that must work without JavaScript can read the URL-encoded fields itself:

src/input.ts
import type { Request } from 'ajo-kit'

/** The submitted fields: parsed JSON from action(), or a native URL-encoded form post. */
export async function input(req: Request): Promise<Record<string, unknown>> {
  if (!req.headers['content-type']?.includes('application/x-www-form-urlencoded')) return req.body ?? {}
  const bytes = await req.read(16 * 1024)
  return Object.fromEntries(new URLSearchParams(new TextDecoder().decode(bytes)))
}

Then validate with parse(Note, await input(req)). Repeated fields keep only their last value in this sketch.

CSRF and sessions

Actions run with the visitor’s cookies, so a page on another site must not be able to trigger them. With ajo-kit-auth, add its CSRF middleware after the session middleware in the root wares.ts, as the starter does:

src/wares.ts
import type { Middleware } from 'ajo-kit'
import { wares } from 'ajo-kit-auth'

export default [wares.session(), wares.csrf] satisfies Middleware[]

It checks unsafe requests, including actions such as sign-in that run before a session exists, and skips safe methods, bearer-token requests and unauthenticated API requests. A request passes with a signed, session-bound token (the XSRF-TOKEN cookie echoed in an X-XSRF-TOKEN header) or when its Origin or Referer matches the request’s own origin; anything else fails with 403. Browsers send that proof with forms and action() calls from your own pages, so they pass without extra code.

Source of truth: ajo-kit README