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:
| Situation | FormData equivalent | Davaux |
|---|---|---|
| Simple text form | fd.get('email') as string | const { email } = await ctx.form() |
| Multi-select / checkboxes | fd.getAll('tags') as string[] | const { tags } = await ctx.formAll() |
| File upload | fd.get('photo') as File | const { 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.