Requests
Every route handler and middleware receives an AppRequest — a normalized request object with typed accessors over the body, query string, route params, and headers. The object is the same shape regardless of which server adapter handles the request.
import type { AppRequest, AppResponse } from '@rudderjs/contracts'
router.post('/api/users', async (req: AppRequest, res: AppResponse) => {
const name = req.string('name')
const email = req.string('email')
const role = req.string('role', 'user')
const user = await User.create({ name, email, role })
return res.status(201).json({ data: user })
})Standard fields
| Field | Type | Description |
|---|---|---|
req.method |
string |
HTTP method ('GET', 'POST', …) |
req.url |
string |
Full URL including query string |
req.path |
string |
URL path only |
req.query |
Record<string, string> |
Parsed query string |
req.params |
Record<string, string> |
Route parameters captured by :slug segments |
req.headers |
Record<string, string> |
Request headers (lowercased keys) |
req.body |
unknown |
Parsed body (JSON, form-encoded, or raw) |
req.ip |
string | undefined |
Client IP, normalized — see below |
req.raw |
unknown |
Adapter-specific raw request (escape hatch) |
Typed input accessors
The same data lives in params, query, and body — fetching by source means three different lookups. Use the input accessors instead. They merge all three sources (priority: params > body > query), parse the value to a known type, and throw a clear InputTypeError if the input doesn't fit.
const id = req.integer('id')
const page = req.integer('page', 1)
const since = req.date('since', new Date(0))
const flag = req.boolean('active', false)
const tags = req.array('tags') // accepts arrays, "a,b,c", or '["a","b"]'| Method | Returns | Notes |
|---|---|---|
req.input<T>(key, fallback?) |
T |
Raw merged value |
req.string(key, fallback?) |
string |
Throws on object/array values |
req.integer(key, fallback?) |
number |
Throws on non-parseable |
req.float(key, fallback?) |
number |
Throws on non-parseable |
req.boolean(key, fallback?) |
boolean |
Truthy: true, '1', 'yes', 'on' |
req.date(key, fallback?) |
Date |
Throws on non-parseable |
req.array(key, fallback?) |
unknown[] |
Accepts arrays, CSV strings, JSON arrays |
req.has(key) |
boolean |
Key present in any source |
req.missing(key) |
boolean |
Key absent from all sources |
req.filled(key) |
boolean |
Key present and value is non-empty |
These are the right tool for a quick endpoint or controller. For complex validation across multiple fields, see Validation — validate() and FormRequest give you Zod schemas with structured 422 responses.
Client IP
req.ip is set by the server adapter. With TRUST_PROXY=true, proxy headers win — the rightmost x-forwarded-for entry (the address the trusted proxy appended; set TRUST_PROXY to a number N to trust N chained proxies and read the Nth-from-right entry), then x-real-ip; with it off, client-sent proxy headers are ignored. The rightmost (never the leftmost) entry is the one a client can't forge, since a proxy appends its observed peer to whatever the client sent. In every case the direct socket address is the fallback wherever the runtime exposes one (the production vike server and adapter.listen() both do; in dev the rudderjs:ip Vite plugin injects a stand-in header). It normalizes IPv6 loopback (::1 → 127.0.0.1). The type is string | undefined — undefined only on edge runtimes with no socket and no trusted header.
const limiter = RateLimit.perMinute(60).by((req) => req.user?.id ?? req.ip)Always read req.ip instead of pulling raw headers — the server normalizes for you, and a custom .by() function reading raw headers will get inconsistent results across dev and production.
Authenticated user
When a route runs through the web middleware group, AuthMiddleware populates req.user:
Route.get('/dashboard', (req, res) => {
if (!req.user) return res.redirect('/login')
return res.json({ user: req.user })
})API routes are stateless by default — req.user is undefined unless you opt into bearer auth per-route with RequireBearer() from @rudderjs/passport. See Middleware.
Session
When @rudderjs/session is installed, the session middleware (auto-installed on the web group) attaches a request-scoped store. Read it via the Session facade — never directly off req:
import { Session } from '@rudderjs/session'
const flash = Session.getFlash('flash.success')
Session.put('lastViewedAt', Date.now())The facade reads from AsyncLocalStorage, so it works inside controllers, services, and helpers without threading req through every call.
Headers
Headers are lowercased on the way in to make lookups case-insensitive:
const ua = req.headers['user-agent']
const auth = req.headers['authorization']
const trace = req.headers['x-request-id'] ?? crypto.randomUUID()File uploads
Multipart uploads parse into req.body as a structure with named files. Use @rudderjs/storage to persist them:
import { Storage } from '@rudderjs/storage'
router.post('/api/avatars', async (req, res) => {
const file = (req.body as { avatar: File }).avatar
const path = await Storage.disk('s3').put(`avatars/${file.name}`, file)
return res.status(201).json({ path })
})Pitfalls
req.paramsisRecord<string, string>only. Numeric:idarrives as a string. Usereq.integer('id')if you need a number.- Object/array bodies in
req.string(key).req.string()throws if the input is an object or array — usereq.input(key)for the raw value or pass throughvalidate()for nested shapes. - Headers as arrays. Some adapters surface multi-value headers; the normalized
req.headersflattens them to a single string. For multi-value headers reach intoreq.raw. - Reading raw IP headers in
RateLimit.by(). Usereq.ip— the dev-mode Vite plugin injectsx-real-ipfrom the Node socket, but onlyreq.ipreads it correctly across environments.