Request Context

Every Davaux handler — pages, API routes, layouts, middleware, and error pages — receives a RequestContext object as its first argument (conventionally called ctx). It bundles together everything you need to read request data, write response headers, and pass information between middleware and handlers.

ctx.params

Typed route parameters extracted from the URL path. For a route at src/routes/blog/[slug].page.tsx, visiting /blog/hello-world gives:

ctx.params.slug  // "hello-world"

All param values are strings. Use ExtractParams<'path/[param]'> for compile-time type safety:

import type { ExtractParams } from 'davaux'

type Params = ExtractParams<'shop/[category]/[product]'>
// { category: string; product: string }

export default definePage((ctx) => {
  const { category, product } = ctx.params as Params
  // ...
})

ctx.query

A standard URLSearchParams instance for reading query string values:

// GET /search?q=davaux&page=2
const q    = ctx.query.get('q')     // "davaux"
const page = ctx.query.get('page')  // "2"
const all  = ctx.query.getAll('tag') // string[]

ctx.url

The full URL object for the current request:

ctx.url.pathname   // "/blog/hello-world"
ctx.url.hostname   // "localhost"
ctx.url.searchParams  // same as ctx.query
ctx.url.href       // full URL string

This is particularly useful in layouts for determining the active nav link:

const isActive = ctx.url.pathname === '/about'

ctx.basePath

The path prefix configured for subdirectory deployments. Set via ssg.basePath in davaux.config.ts:

// davaux.config.ts
export default defineConfig({
  ssg: {
    basePath: '/docs',
  },
})

At runtime, ctx.basePath reflects this value:

ctx.basePath  // "/docs" during static generation, "" in dev and SSR

All links that should respect this prefix need to prepend it manually, or use createLink which handles it automatically. ctx.basePath is an empty string "" when no basePath is configured, so code written against it works correctly in dev and SSR without any special-casing.

createLink(ctx)

A factory that returns a base-path-aware link function and Link component scoped to the current request. Import from 'davaux' and call with ctx:

import { createLink } from 'davaux'

export default defineLayout(({ children, ctx }) => {
  const { link, Link } = createLink(ctx)

  // link() — resolves a root-relative path against the configured basePath
  link('/about')               // → "/docs/about" when basePath is "/docs"
  link('https://example.com')  // → "https://example.com" (external, unchanged)

  return (
    <nav>
      <Link href="/about">About</Link>
    </nav>
  )
})

Link renders a standard <a> element with href automatically run through link(). It accepts all standard anchor props plus two extras:

activeClass

Applies a CSS class when the resolved href matches ctx.url.pathname. No manual comparison needed:

<Link href="/docs" activeClass="active">Docs</Link>

The class is appended to any existing class prop value.

external

Shorthand for target="_blank" rel="noopener noreferrer". Explicit target or rel props take precedence if provided:

<Link href="https://github.com/example/repo" external>
  View on GitHub
</Link>

External URLs (https://, //, mailto:, etc.) pass through link() unchanged regardless of basePath.

ctx.cookies

A CookieJar for reading and writing HTTP cookies:

// Reading
const token = ctx.cookies.get('auth_token')

// Writing (sets Set-Cookie header)
ctx.cookies.set('session_id', 'abc123', {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',
  maxAge: 60 * 60 * 24 * 7,  // 7 days in seconds
  path: '/',
})

// Deleting (sets Max-Age=0)
ctx.cookies.delete('session_id')

Cookie options follow the standard web cookie spec: httpOnly, secure, sameSite ('strict' | 'lax' | 'none'), maxAge, expires, path, domain.

ctx.head

Controls the <head> of the rendered page. Values set here are read by your root _layout.tsx to produce the actual HTML:

ctx.head.title        = 'My Page Title'
ctx.head.description  = 'A short description for search engines.'
ctx.head.meta['og:image'] = 'https://example.com/og.png'

// Inject extra stylesheets
ctx.head.stylesheets.push('/css/special.css')

// Inject extra scripts (rendered at bottom of <body>)
ctx.head.scripts.push('/js/analytics.js')

ctx.state

An open key-value bag for passing data from middleware to downstream handlers. Middleware runs first, can populate ctx.state, and then the page or API handler reads it:

// _middleware.ts
export default defineMiddleware(async (ctx, next) => {
  const user = await db.users.find(ctx.cookies.get('uid'))
  ctx.state.user = user
  return next()
})

// dashboard.page.tsx
export default definePage((ctx) => {
  const user = ctx.state.user  // set by middleware
  return <h1>Welcome, {user.name}!</h1>
})

For full TypeScript support, augment the State interface in a .d.ts file:

// src/types.d.ts
import 'davaux'

declare module 'davaux' {
  interface State {
    user?: { id: string; name: string; email: string }
  }
}

ctx.json(validator?, options?)

Parses the request body as JSON. Optionally accepts a validator — any object with a .parse(unknown): T method (Zod schemas, valibot, arktype all work). On validation failure, Davaux automatically returns 400 Bad Request.

Default body size limit is 4 MB. Override with options.maxBytes.

// Without validation
const body = await ctx.json()

// With a Zod schema
import { z } from 'zod'

const CreateUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
})

export default defineHandler(async (ctx) => {
  const input = await ctx.json(CreateUserSchema)
  // input is typed as { name: string; email: string }
  return { created: input.name }
})

// Custom body size limit
const body = await ctx.json(undefined, { maxBytes: 10_000_000 }) // 10 MB

ctx.form(validator?, options?)

Parses application/x-www-form-urlencoded request bodies. Returns Record<string, string> — access fields directly by key. When a field name appears more than once, the last value wins. Use ctx.formAll() when you need all values for a repeated field.

Default body size limit is 1 MB. Override with options.maxBytes.

export default defineHandler(async (ctx) => {
  const data = await ctx.form()
  const email = data.email   // string | undefined
  // ...
})

// With validation
const data = await ctx.form(MySchema)

// Custom body size limit
const data = await ctx.form(undefined, { maxBytes: 5_000_000 })

When the body exceeds the limit, Davaux returns 413 Payload Too Large automatically. No try/catch needed.

ctx.formAll(validator?, options?)

Like ctx.form(), but returns Record<string, string[]> — every key maps to an array of all submitted values. Use this for <select multiple> and repeated checkbox groups where ctx.form() would silently drop all but the last value.

// HTML: <select name="tags" multiple>
//         <option value="tech">Tech</option>
//         <option value="news">News</option>
//       </select>

export default defineHandler(async (ctx) => {
  const data = await ctx.formAll()
  const tags = data.tags ?? []   // ['tech', 'news']
  const title = (data.title ?? [])[0] ?? ''  // single-value fields are still arrays
})

Single-value fields are wrapped in a one-element array, so you can always use the same access pattern. Pass a validator to narrow the shape:

import { z } from 'zod'

const Schema = z.object({
  title: z.array(z.string()).transform((a) => a[0] ?? ''),
  tags: z.array(z.string()),
})

const data = await ctx.formAll(Schema)
data.title  // string
data.tags   // string[]

ctx.multipart(options?)

Parses a multipart/form-data request body (the encoding used by file inputs). Returns an object with text fields and uploaded files. Default body size limit is 10 MB. Override with options.maxBytes.

import { defineHandler } from 'davaux'
import { writeFile } from 'node:fs/promises'

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

  const caption = fields.caption ?? ''
  const photo = files.photo

  if (photo) {
    // photo.filename — original name from the browser
    // photo.mimetype — e.g. 'image/jpeg'
    // photo.data     — Buffer of raw file bytes
    await writeFile(`public/uploads/${photo.filename}`, photo.data)
  }

  return { ok: true }
})
// types
interface MultipartResult {
  fields: Record<string, string>        // text fields
  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 form must use enctype="multipart/form-data":

<form method="post" enctype="multipart/form-data">
  <input type="text"  name="caption" />
  <input type="file"  name="photo" accept="image/*" />
  <button type="submit">Upload</button>
</form>

Custom size limit:

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

Throws if the request is missing a boundary in its Content-Type. The entire body is buffered in memory — for very large uploads, stream via ctx.req directly.

ctx.send(body, contentType, status?)

Write a raw response with a custom content-type. Useful for XML, plain text, CSV, RSS feeds, and any format that isn't JSON. The response is sent immediately; the framework skips its normal JSON serialization.

// src/routes/blog/rss.xml.get.ts
import { defineHandler } from 'davaux'

export default defineHandler((ctx) => {
  const xml = buildRssFeed()
  ctx.send(xml, 'application/xml; charset=utf-8')
})

// Plain text
ctx.send('pong', 'text/plain')

// Custom status
ctx.send(csvData, 'text/csv; charset=utf-8', 200)

body accepts a string or Buffer. status defaults to 200. Calling ctx.send() after the response is already sent is a safe no-op.

ctx.flash(key, value?) — flash messages

Write a one-time message that survives a single redirect, then vanishes. Requires @davaux/session.

// In an action — write a message before redirecting
export const action = defineAction(async (ctx) => {
  await db.posts.publish(ctx.params.id)
  ctx.flash('notice', 'Post published.')
  redirect('/dashboard/posts')
})

// On the destination page — read and consume it
export default definePage((ctx) => {
  const notice = ctx.flash('notice')  // string | undefined — gone after this read
  return (
    <main>
      {notice && <p class="notice">{notice}</p>}
      {/* ... */}
    </main>
  )
})

Reading a flash key consumes it — the next request won't see it. Multiple keys are supported.

ctx.defer(name, promise) — deferred slots

Register a named deferred HTML slot and return a placeholder node. The framework streams the resolved content into the page after the initial shell has been sent. Polyfill scripts are injected automatically.

const slot = ctx.defer('feed', getFeed().then((items) => <Feed items={items} />))

return (
  <Layout>
    <header>{/* fast content */}</header>
    {slot}  {/* renders as a <?marker> placeholder; fills in when the promise resolves */}
  </Layout>
)

See Partial Updates for the full guide.

ctx.req and ctx.res

The raw Node.js http.IncomingMessage and http.ServerResponse objects. These are escape hatches for when you need low-level access — reading streams, writing chunked responses, or using third-party Node.js libraries that expect the raw objects:

import { pipeline } from 'node:stream/promises'

export default defineHandler(async (ctx) => {
  // Stream a file directly to the response
  ctx.res.setHeader('Content-Type', 'application/octet-stream')
  await pipeline(fs.createReadStream('/path/to/file'), ctx.res)
})

Prefer ctx.send() over ctx.res.writeHead + ctx.res.end for one-shot custom responses — it's shorter and handles the headersSent guard for you.

redirect(url, status?)

Throws a special RedirectError that Davaux catches and converts to a proper HTTP redirect response. Call it anywhere in a handler, middleware, or layout:

import { definePage, redirect } from 'davaux'

export default definePage(async (ctx) => {
  const user = ctx.state.user
  if (!user) {
    redirect('/login')             // 302 by default
    // or: redirect('/login', 301) // permanent
  }
  return <Dashboard user={user} />
})

Because redirect() throws, TypeScript understands that code after it is unreachable — no return needed.

Request validation with Zod

Pass a Zod schema (or any object with .parse()) directly to ctx.json() or ctx.form(). On failure, Davaux catches the thrown error and automatically returns 400 Bad Request — no try/catch needed in your handler.

// src/routes/api/users.post.ts
import { defineHandler } from 'davaux'
import { z } from 'zod'

const CreateUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  role: z.enum(['admin', 'member']).default('member'),
})

export default defineHandler(async (ctx) => {
  const input = await ctx.json(CreateUserSchema)
  // input: { name: string; email: string; role: 'admin' | 'member' }

  ctx.res.statusCode = 201
  return await db.users.create(input)
})