HTTP methods
Export a named HTTP handler from a route. Handlers receive (req, res) and may be async:
import type { Handler } from "goribu/server";
// Return JSON without defining a page component.
export const GET: Handler = (req, res) => {
return res.json({ name: req.query.get("name") ?? "friend" });
};
// Return JSON without defining a page component.
export const GET = (req, res) => {
return res.json({ name: req.query.get("name") ?? "friend" });
};
- Supported methods: Export
GET,POST,PUT,PATCH,DELETE,HEAD, orOPTIONS. - Export casing: Lowercase or mixed-case handler exports fail the build. Rename exports such as
gettoGET. - Return contract: Return a
res.*result, not a rawResponse, JSX or a plain object. - Page responses: A default React export adds a page.
res.render(props)renders that page with serializable data. - Binary responses: Use
res.stream(body)for binary or streamed bodies.
Reading the request
| API | Use |
|---|---|
req.method, req.path | HTTP method and URL pathname. |
req.url | The absolute URL of the handled route, as a URL. |
req.params | Dynamic path segments as strings. |
req.query | URLSearchParams.Use get() or getAll() to access. |
req.headers | The request's Headers. |
req.env | Worker bindings and server environment values. |
req.cookies | Read and queue cookies. |
req.d1, req.kv | Request-scoped data APIs. |
req.cache | JSON get/set/delete with no binding setup, see Cache. |
Use req.url.origin for links that must name the current host, such as an OAuth callback, instead of reading the Host header:
const callback = new URL("/auth/callback", req.url.origin).href;
const callback = new URL("/auth/callback", req.url.origin).href;
- Client navigation:
req.urlis the target page's URL, not Goribu's internal endpoint.req.pathandreq.queryare the target page's pathname and search parameters. - Build-time URLs: A handler that runs during pre-rendering sees the build's local origin. Build absolute links on static pages from your public origin.
Reading the body
Always await req.body(). Its result depends on the content type:
| Content type | Result |
|---|---|
application/json | A JSON object; arrays and primitive roots are rejected. |
application/x-www-form-urlencoded | An object of fields; repeated names become arrays. |
multipart/form-data | An object of fields, with uploads as File values. Repeated names become arrays. |
text/* | A string. |
| Empty or unsupported type | An empty object. |
Both form encodings return a field object. Validate each value before using it:
import type { Handler } from "goribu/server";
// Validate the value regardless of the form's encoding.
export const POST: Handler = async (req, res) => {
const { name } = await req.body() as Record<string, unknown>;
if (typeof name !== "string" || !name.trim()) {
return res.invalid({ name: "Enter your name." });
}
return res.json({ name: name.trim() });
};
// Validate the value regardless of the form's encoding.
export const POST = async (req, res) => {
const { name } = await req.body();
if (typeof name !== "string" || !name.trim()) {
return res.invalid({ name: "Enter your name." });
}
return res.json({ name: name.trim() });
};
- TypeScript: The
Record<string, unknown>assertion allows field access without a runtime object check. It does not validate the body or its fields. A text body givesundefinedforname, so the field validation above rejects it. - Missing fields: Missing form fields are absent from the body object so reading one returns
undefined. - Repeated fields: Repeated names become arrays, including repeated files or mixed text and files.
- Literal values: Form values are not coerced and bracketed field names remain literal.
- Malformed bodies: Malformed JSON or multipart data produce a safe
400response. - Raw bytes:
await req.rawBody()returns aUint8Arrayfor webhook signatures (like Stripe's) or custom parsing. - Shared body read:
req.body()andreq.rawBody()share one body read. Either helper can be called first.
Handle missing, single and repeated values explicitly:
import type { Handler } from "goribu/server";
// Accept one or several form fields named "tag".
export const POST: Handler = async (req, res) => {
const { tag } = await req.body() as Record<string, unknown>;
const submittedTags = tag === undefined ? [] : tag;
const tags = Array.isArray(submittedTags) ? submittedTags : [submittedTags];
if (tags.some((value) => typeof value !== "string" || !value.trim())) {
return res.invalid({ tag: "Use non-empty text tags." });
}
return res.json({ tags: tags.map((value) => value.trim()) });
};
// Accept one or several form fields named "tag".
export const POST = async (req, res) => {
const { tag } = await req.body();
const submittedTags = tag === undefined ? [] : tag;
const tags = Array.isArray(submittedTags) ? submittedTags : [submittedTags];
if (tags.some((value) => typeof value !== "string" || !value.trim())) {
return res.invalid({ tag: "Use non-empty text tags." });
}
return res.json({ tags: tags.map((value) => value.trim()) });
};
Choosing a response
| Return | Result |
|---|---|
res.render(props) | Render this route's default page. |
res.json(data) | Return JSON. |
res.text(body) | Return plain text. |
res.stream(body) | Send bytes from a ReadableStream, ArrayBuffer, typed array, or Blob (available since 0.9.2). |
res.invalid(errors) | Return form validation errors, normally with status 422. |
res.redirect(path) | Redirect, normally with status 303. |
res.notFound(props) | Render the app's 404 page. |
You can use res.status(code) and res.headers(object) before a terminal helper:
return res.status(201).headers({ "x-created": "yes" }).json({ id: 7 });
return res.status(201).headers({ "x-created": "yes" }).json({ id: 7 });
- Bodyless statuses: Statuses such as 204 cannot carry JSON or text.
- HTML responses: There is no
res.html(); render a page to return HTML.
Streaming bytes
res.stream(body) sends binary or streamed bytes, such as a file from R2 or a proxied fetch body, without buffering them:
import type { Handler } from "goribu/server";
// Serve a private R2 file through an app route after checking access.
export const GET: Handler = async (req, res) => {
const file = await req.r2.private.get(`avatars/${req.params.id}`);
if (!file) return res.notFound();
// Bridge Cloudflare's R2 stream declaration to the Web API stream type.
return res
.headers({ "content-type": file.httpMetadata?.contentType ?? "application/octet-stream" })
.stream(file.body as ReadableStream<Uint8Array>);
};
// Serve a private R2 file through an app route after checking access.
export const GET = async (req, res) => {
const file = await req.r2.private.get(`avatars/${req.params.id}`);
if (!file) return res.notFound();
// Bridge Cloudflare's R2 stream declaration to the Web API stream type.
return res
.headers({ "content-type": file.httpMetadata?.contentType ?? "application/octet-stream" })
.stream(file.body);
};
- Content type:
res.stream()defaults toapplication/octet-stream. Set a different content type and any caching headers withres.headers(). - Response options: Status, headers, and cookies apply to streams as for any other result.
- HEAD requests: A
HEADrequest to a streamed route receives headers without the body. - Text bodies: Use
res.text()for strings. - Range and ETag: Goribu adds no range or
ETaghandling. Set those headers yourself if you need them. - Client navigation: Links to handlers returning
res.stream(...)trigger a full browser load so the browser can handle the raw response. - Pre-rendering: A route that streams is never prerendered.
Redirects
- After a mutation: Return
res.redirect("/posts")to navigate after saving a form. - Default status: A redirect defaults to 303, telling a native browser submission to follow with GET.
- Hydrated Forms: Hydrated Forms follow redirects without repeating the mutation. And that means no annoying "Re-submit the form" warnings when clicking back. 👍
For a permanent redirect, set the status explicitly:
import type { Handler } from "goribu/server";
// Move an old URL to its replacement.
export const GET: Handler = (req, res) => {
return res.status(301).redirect("/");
};
// Move an old URL to its replacement.
export const GET = (req, res) => {
return res.status(301).redirect("/");
};
Cookies
- Response delivery: Cookie changes are added to the final response, including redirects.
- Security options: Cookie options are explicit and Goribu does not add security defaults.
import type { Handler } from "goribu/server";
// Count visits using a cookie scoped to the whole app.
export const GET: Handler = (req, res) => {
const visits = Number(req.cookies.get("visits") ?? "0") + 1;
req.cookies.set("visits", String(visits), {
path: "/",
maxAge: 86400,
httpOnly: true,
sameSite: "Lax",
});
return res.json({ visits });
};
// Count visits using a cookie scoped to the whole app.
export const GET = (req, res) => {
const visits = Number(req.cookies.get("visits") ?? "0") + 1;
req.cookies.set("visits", String(visits), {
path: "/",
maxAge: 86400,
httpOnly: true,
sameSite: "Lax",
});
return res.json({ visits });
};
- Read:
req.cookies.get(name)returns a string ornull. - Set options:
req.cookies.set()acceptspath,domain,expires,maxAge,httpOnly,secureandsameSite. - Value encoding: Encode structured or Unicode values before storing them.
- Delete: Use
req.cookies.delete("visits", { path: "/" })with the same path and domain as the original cookie. - Trust boundary: A cookie value supplied by the browser is input to validate, not proof of identity.
Middleware
A default export in _middleware.ts_middleware.js runs before routes in its folder and subfolders:
import type { Middleware } from "goribu/server";
// Add a diagnostic header, then continue to the route.
const requestId: Middleware = (req, res) => {
res.headers({ "x-request-id": crypto.randomUUID() });
};
export default requestId;
// Add a diagnostic header, then continue to the route.
const requestId = (req, res) => {
res.headers({ "x-request-id": crypto.randomUUID() });
};
export default requestId;
- Folder order: Parent-folder middleware runs before child-folder middleware.
- Continue or stop: Return nothing from middleware to continue, or return a
res.*result to stop. There is nonext(). - Function arrays: A middleware file can default-export an array of functions. They get executed from first to last.
- Shared context: Middleware and the handler share the same request and response builder.
Passing data to the route
Attach loaded data to req, then read it in later middleware or the handler, for example req.user.name:
import type { Middleware } from "goribu/server";
// Load the user once, then read req.user in the handler.
const loadUser: Middleware = async (req, res) => {
const user = await findSessionUser(req);
if (!user) return res.redirect("/login");
req.user = user;
};
export default loadUser;
// Load the user once, then read req.user in the handler.
const loadUser = async (req, res) => {
const user = await findSessionUser(req);
if (!user) return res.redirect("/login");
req.user = user;
};
export default loadUser;
- Lifetime: Values attached to
reqlast for one request. - Field names: Use your own names, such as
userorteam. Do not overwrite Goribu's fields, such asmethod,path,cookies, orenv.
In TypeScript, augment AppRequestFields once. Every Handler and Middleware then accepts those fields:
import type { User } from "./lib/users";
declare module "goribu/server" {
interface AppRequestFields {
user?: User;
}
}
This declaration file also works in JavaScript projects that use editor type checking. Keep a field optional unless the middleware that sets it applies to every route.
- Scope and rendering: Keep middleware scoped to routes that need it. Affected pages are always Worker-served.
- Skipped requests: Middleware does not run for unmatched URLs, automatic OPTIONS and 405 responses, or Goribu's file endpoints under
/_goribu/, such as signed R2 downloads. - Signed downloads: When creating a signed R2 link, authorize access before as the download endpoint does not run route middleware.
- Navigation and uploads: Client navigation and the start of an R2 upload run the target route's middleware.
Server-only work
Put private helpers in .server.ts or .server.js files outside the routes folder. Import them from handlers or middleware, never from browser-reachable components.
- Server module chains: Server modules may import other server modules. A chain such as
report.ts → body.server.ts → images.server.tsreport.js → body.server.js → images.server.jsworks normally. - Browser module cleanup: Before building a route's browser module, Goribu removes its HTTP handlers,
meta,prerender, anduploadsexports, plus code only they use, including server-only initializer calls. - Shared helpers: Helpers shared with the page stay in the browser module and cannot depend on a
.server.*module. - Boundary enforcement: Development and production both reject a
.server.*import that reaches the browser code. - Named re-exports: Browser-reachable route files must use explicit named re-exports.
export *is rejected there. - Side effects and eval: Bare side-effect imports such as
import "../instrument.server"and directevalare rejected in browser-reachable route files. Move them into a.server.*helper used only by a handler. - Underscore folders: Helpers in
_-prefixed folders are not rewritten and may useexport *.
// Name each re-exported handler; export * is rejected in route files.
export { load as GET } from "../lib/report.server";
// Name each re-exported handler; export * is rejected in route files.
export { load as GET } from "../lib/report.server";
- Background work: Use
req.waitUntil(promise)for work that may finish after the response. - Required writes: Await writes that must succeed before telling the user their change was saved.