Form Actions

Davaux lets you co-locate a form's POST handler with its page. The page handles GET requests (renders the form), and the action export handles POST requests (processes the submission). No JavaScript is required on the client — plain HTML forms work exactly as they should.

Defining an action

Export an action function alongside your page's definePage default export. Use defineAction to wrap it:

// src/routes/contact.page.tsx
import { definePage, defineAction, redirect } from 'davaux'

export const action = defineAction(async (ctx) => {
  const data = await ctx.form()
  const email = data.email as string
  const message = data.message as string

  await db.messages.create({ email, message })

  // Redirect after successful POST (POST/Redirect/GET pattern)
  redirect('/contact/thanks')
})

export default definePage((ctx) => {
  return (
    <main>
      <h1>Contact Us</h1>
      <form method="post">
        <label>
          Email
          <input type="email" name="email" required />
        </label>
        <label>
          Message
          <textarea name="message" rows={5} required />
        </label>
        <button type="submit">Send</button>
      </form>
    </main>
  )
})

Accessing the action result

When the action returns a value instead of redirecting, Davaux re-renders the page and makes that value available as ctx.state.actionResult. Use it to show validation errors or success messages:

// src/routes/login.page.tsx
import { definePage, defineAction, redirect } from 'davaux'
import { verifyCredentials } from '../lib/auth.ts'

interface ActionResult {
  error?: string
}

export const action = defineAction(async (ctx): Promise<ActionResult> => {
  const data = await ctx.form()
  const { email, password } = data

  const user = await verifyCredentials(email, password)
  if (!user) {
    // Return an error — the page re-renders with this value
    return { error: 'Invalid email or password.' }
  }

  ctx.cookies.set('session', user.sessionToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 14,
  })

  redirect('/dashboard')
})

export default definePage((ctx) => {
  const result = ctx.state.actionResult as ActionResult | undefined

  return (
    <main class="auth-page">
      <h1>Sign In</h1>

      {result?.error && (
        <div class="error-banner" role="alert">
          {result.error}
        </div>
      )}

      <form method="post">
        <label>
          Email
          <input
            type="email"
            name="email"
            autocomplete="email"
            required
          />
        </label>
        <label>
          Password
          <input
            type="password"
            name="password"
            autocomplete="current-password"
            required
          />
        </label>
        <button type="submit">Sign In</button>
      </form>

      <p><a href="/forgot-password">Forgot your password?</a></p>
    </main>
  )
})

The POST/Redirect/GET pattern

After a successful form submission, always call redirect() to send the browser to a confirmation page. This prevents duplicate submissions if the user refreshes or navigates back:

export const action = defineAction(async (ctx) => {
  const data = await ctx.form()
  await processForm(data)
  redirect('/success')   // user sees /success, not the POST endpoint
})

If you return a value without redirecting, the browser's back button and refresh will replay the POST — usually not what you want for destructive or side-effectful operations.

Validation with Zod

Use ctx.form() with a validator for type-safe, validated form data:

import { definePage, defineAction } from 'davaux'
import { z } from 'zod'

const SignupSchema = z.object({
  username: z.string().min(3).max(20).regex(/^[a-z0-9_]+$/),
  email: z.string().email(),
  password: z.string().min(8),
})

type SignupErrors = Partial<Record<keyof z.infer<typeof SignupSchema>, string>>

export const action = defineAction(async (ctx): Promise<{ errors: SignupErrors } | void> => {
  const raw = await ctx.form()
  const result = SignupSchema.safeParse({
    username: raw.username,
    email: raw.email,
    password: raw.password,
  })

  if (!result.success) {
    const errors: SignupErrors = {}
    for (const issue of result.error.issues) {
      const key = issue.path[0] as keyof SignupErrors
      errors[key] = issue.message
    }
    return { errors }
  }

  await db.users.create(result.data)
  redirect('/welcome')
})

export default definePage((ctx) => {
  const result = ctx.state.actionResult as { errors: SignupErrors } | undefined
  const errors = result?.errors ?? {}

  return (
    <form method="post">
      <label>
        Username
        <input type="text" name="username" />
        {errors.username && <span class="field-error">{errors.username}</span>}
      </label>
      <label>
        Email
        <input type="email" name="email" />
        {errors.email && <span class="field-error">{errors.email}</span>}
      </label>
      <label>
        Password
        <input type="password" name="password" />
        {errors.password && <span class="field-error">{errors.password}</span>}
      </label>
      <button type="submit">Create account</button>
    </form>
  )
})

Multi-select and repeated fields

ctx.form() returns one string per field name — when the same name appears multiple times (e.g. <select multiple> or multiple checkboxes), only the last value is kept. Use ctx.formAll() to get every submitted value as an array:

// HTML: <select name="categories" multiple>…</select>

export const action = defineAction(async (ctx) => {
  const data = await ctx.formAll()
  const categories = data.categories ?? []  // string[]
  await db.post.update({ categories })
  redirect('/dashboard')
})

Single-value fields are also arrays in formAll(), so you can mix them freely:

const data = await ctx.formAll()
const title    = (data.title ?? [])[0] ?? ''  // first (and only) value
const tags     = data.tags ?? []              // all selected tags

Body size limits

ctx.form() enforces a 1 MB default body size limit. Requests larger than this receive a 413 Payload Too Large response automatically — no try/catch needed in your action.

Override the limit per-call with the second argument:

export const action = defineAction(async (ctx) => {
  // Allow up to 5 MB for this particular form
  const data = await ctx.form(undefined, { maxBytes: 5_000_000 })
  // ...
})

The raw body is read and cached on the first ctx.form() call. If @davaux/csrf reads the body before your action (which it does for CSRF token verification), its limit is the one that applies — keep your forms small or raise the limit in the middleware that reads first.

File uploads

ctx.form() handles application/x-www-form-urlencoded bodies. File inputs (<input type="file">) submit as multipart/form-data — use ctx.multipart() for those:

// src/routes/upload.page.tsx
import { definePage, defineAction, redirect } from 'davaux'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'

export const action = defineAction(async (ctx) => {
  const { fields, files } = await ctx.multipart()

  const title = fields.title ?? ''
  const image = files.image

  if (image) {
    // image.filename — original filename from the browser
    // image.mimetype — e.g. 'image/jpeg'
    // image.data     — Buffer containing the file bytes
    await writeFile(join('public/uploads', image.filename), image.data)
  }

  redirect('/gallery')
})

export default definePage(() => (
  <form method="post" enctype="multipart/form-data">
    <input type="text" name="title" placeholder="Caption" />
    <input type="file" name="image" accept="image/*" />
    <button type="submit">Upload</button>
  </form>
))

ctx.multipart() returns:

interface MultipartResult {
  fields: Record<string, string>          // text inputs
  files: Record<string, MultipartFile>   // file inputs
}

interface MultipartFile {
  filename: string   // original filename reported by the browser
  mimetype: string   // Content-Type of the file part
  data: Buffer       // raw file bytes
}

The default body size limit for ctx.multipart() is 10 MB. Override it per-call:

const { fields, files } = await ctx.multipart({ maxBytes: 50_000_000 })  // 50 MB

Note: The entire body is buffered in memory. For large files, process uploads asynchronously and consider a streaming approach via ctx.req.

Coming from Remix or SvelteKit?

Those frameworks route all form handling through the web FormData API (await request.formData()), which returns an opaque object where every field is string | File | null and requires .get() / .getAll() calls to read values.

Davaux splits this into three purpose-built methods with cleaner types:

SituationFormData equivalentDavaux
Simple text formfd.get('email') as stringconst { email } = await ctx.form()
Multi-select / checkboxesfd.getAll('tags') as string[]const { tags } = await ctx.formAll()
File uploadfd.get('photo') as Fileconst { files } = await ctx.multipart()

The split is intentional. A multipart/form-data request and a application/x-www-form-urlencoded request are fundamentally different wire formats — treating them as one API hides that distinction and forces you to narrow string | File at every field access. Davaux makes the encoding explicit at the call site so the return types are tight by default.