Reading the request
Params, search params, body, cookies, negotiation.
Inside a handler you read the request through composables - small functions like useRequest and useSearchParams that you call instead of threading a request object through your code. Each request runs in its own async scope, so any function a handler calls - however deep, across every await - reads the same request. Middleware shares the scope too.
// api/search.get.ts
import { defineHandler, useSearchParams } from 'ohnejs';
export default defineHandler(() => {
const { q } = useSearchParams();
return { query: q };
});The event
useEvent() returns the request's Event, the one object every composable reads from. It carries the inbound request and parsed url, the matched route params, the client ip (an empty string when the transport cannot resolve one), the mutable response state, and context.
const event = useEvent();
event.request.method // -> 'GET'
event.url.pathname // -> '/search'
event.ip // -> '203.0.113.7'Outside a request there is no event, so useEvent() throws. tryUseEvent() is the non-throwing counterpart, returning undefined, for code that runs with or without a request.
context is an extensible per-request bag. It starts empty; middleware fills it - an auth session, a resolved user. Augment its type from your app with declare module:
declare module 'ohnejs' {
interface EventContext {
auth: Session;
}
}The response side of the event - status, headers - is covered in shaping the response.
The request
useRequest() returns the inbound request as the Web standard Request, shorthand for useEvent().request. Method, headers, everything the platform type offers:
useRequest().method // -> 'POST'
useRequest().headers.get('user-agent') // -> the header valueRoute params
useRouteParams() returns the values the route pattern captured, keyed by name. A [id] segment becomes params.id; a catch-all [...path] captures the rest of the path, slashes included, as params.path. Values are always strings, URI-decoded.
// api/authors/[id].get.ts, matched against /authors/42
import { defineHandler, useRouteParams } from 'ohnejs';
export default defineHandler(() => {
return { id: useRouteParams().id };
});The handler's context argument carries the same map, so defineHandler(({ params }) => ...) works too, and annotating the context narrows params to specific keys. The file-naming convention that produces the pattern lives in route files.
Search params
useSearchParams() parses the URL query into a structured object. Values carry their own type:
// GET /search?q=ohne&page=2&draft=true&tags=[new,sale]
useSearchParams() // -> { q: 'ohne', page: 2, draft: true, tags: ['new', 'sale'] }The grammar:
1is a number,trueandfalsebooleans,nullisnull.[a,b]is a list,{k:v}an object; both nest freely.- A leading backtick forces a string:
`1is the string'1'. - A bare key with no value is
true:?draftis{ draft: true }. - A repeated key collects into an array:
?t=1&t=2is{ t: [1, 2] }.
Typed values cut both ways: ?id=42 arrives as the number 42, not the string '42'. The coercion is conservative - only JSON-shaped numbers convert, so ?id=007 keeps its text (a leading zero is not a number shape) and an integer past the safe range keeps every digit. When a value must arrive as text whatever its shape, send it with the backtick: ?id=`42 is '42'.
Untrusted input never throws: a malformed value falls back to its decoded string, and nesting caps at 32 levels. For the raw, uncoerced URLSearchParams, read useEvent().url.searchParams.
This same grammar drives querying over HTTP, where where, order, and the rest of the query surface are structured values in the URL.
The body
One reader per shape, each valid only within a request:
// api/subscribers.post.ts
import { defineHandler, readJSONBody } from 'ohnejs';
export default defineHandler(async () => {
const input = await readJSONBody<{ email: string }>();
return { subscribed: input.email };
});readJSONBody<T>()parses a JSON body. TheContent-Typemust beapplication/jsonor carry a+jsonsuffix; anything else rejects with415. An empty or malformed body rejects with400. The result is typed asTbut unverified - validate it before you trust it.readTextBody()reads the body as a UTF-8 string,''when there is none. Invalid UTF-8 rejects with400.readFormBody()reads a form intoFormData. It acceptsmultipart/form-dataandapplication/x-www-form-urlencoded; any otherContent-Typerejects with415, a malformed body with400. An absent body yields emptyFormData.readRawBody()returns the bytes as aUint8Array, orundefinedwhen there is no body.
The stream is consumed once and memoized: every reader reuses the same bytes, so reading twice - or as text after raw - costs nothing and never fails on a spent stream.
Bodies are bounded by api.maxBodySize, '1mb' by default; an over-cap body is refused with 413. Raise it in config, or per route through defineHandler options.
Cookies
useCookies() parses the Cookie header into a map of name to value:
// Cookie: id=42; theme=dark
useCookies() // -> { id: '42', theme: 'dark' }setCookie appends a Set-Cookie header - one call per cookie - and deleteCookie clears one by expiring it. Pass the same domain/path the cookie was set with; a browser only clears a matching cookie:
setCookie('theme', 'dark', { path: '/', maxAge: 31536000 });
deleteCookie('theme', { path: '/' });Options cover the full attribute surface: domain, path, expires, maxAge, httpOnly, secure, sameSite, partitioned, and priority.
Signed cookies
A signed cookie proves the value left your server unmodified. setSignedCookie signs with the COOKIE_SECRET env var and defaults to httpOnly, secure, and sameSite: 'lax' - the safe profile for a session:
setSignedCookie('session', userId);useSignedCookies() returns only the cookies whose signature verifies; tampered, unsigned, and renamed ones are dropped. The signature binds the cookie's name too, so a valid value cannot be replayed under another name.
useSignedCookies() // -> { session: 'u42' }Signing is not encryption: the value still travels readable, so never sign a secret. And with no COOKIE_SECRET set, signing throws - the app fails loud instead of signing under a blank key.
Content negotiation
useAccepts picks the best response media type for the Accept header from what you can produce; useAcceptsLanguages does the same for Accept-Language. Both return the chosen offer, or undefined when the client accepts none of them, and both append the header they read to the response Vary.
// Accept-Language: de-AT, de;q=0.9, en;q=0.5
useAcceptsLanguages(['en', 'de']) // -> 'de'A request without the header accepts anything, so the first offer wins - put your preferred format first.
Authorization
useAuthorization() parses the Authorization header into its scheme and credentials, or returns null when the header is absent or carries none. The scheme is lowercased; the token is kept verbatim. A basic token is additionally decoded into username and password:
// Authorization: Bearer abc.def
useAuthorization() // -> { scheme: 'bearer', token: 'abc.def' }
// Authorization: Basic dXNlcjpwYXNz
useAuthorization()
// -> { scheme: 'basic', token: 'dXNlcjpwYXNz', username: 'user', password: 'pass' }Finally, the request carries a language: useT() translates in it, negotiated from Accept-Language or forced by a middleware through context.locale - see messages.