Request handlers

Read the request, do server work, and return the response the user should receive.

On this page

HTTP methods

Export a named HTTP handler from a route. Handlers receive (req, res) and may be async:

src/routes/hello.ts
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" });
};
src/routes/hello.js
// 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, or OPTIONS.
  • Export casing: Lowercase or mixed-case handler exports fail the build. Rename exports such as get to GET.
  • Return contract: Return a res.* result, not a raw Response, 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

APIUse
req.method, req.pathHTTP method and URL pathname.
req.urlThe absolute URL of the handled route, as a URL.
req.paramsDynamic path segments as strings.
req.queryURLSearchParams.Use get() or getAll() to access.
req.headersThe request's Headers.
req.envWorker bindings and server environment values.
req.cookiesRead and queue cookies.
req.d1, req.kvRequest-scoped data APIs.
req.cacheJSON 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:

Inside a handler
const callback = new URL("/auth/callback", req.url.origin).href;
Inside a handler
const callback = new URL("/auth/callback", req.url.origin).href;
  • Client navigation: req.url is the target page's URL, not Goribu's internal endpoint. req.path and req.query are 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 typeResult
application/jsonA JSON object; arrays and primitive roots are rejected.
application/x-www-form-urlencodedAn object of fields; repeated names become arrays.
multipart/form-dataAn object of fields, with uploads as File values. Repeated names become arrays.
text/*A string.
Empty or unsupported typeAn empty object.

Both form encodings return a field object. Validate each value before using it:

src/routes/name.ts
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() });
};
src/routes/name.js
// 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 gives undefined for name, 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 400 response.
  • Raw bytes: await req.rawBody() returns a Uint8Array for webhook signatures (like Stripe's) or custom parsing.
  • Shared body read: req.body() and req.rawBody() share one body read. Either helper can be called first.

Handle missing, single and repeated values explicitly:

src/routes/tags.ts
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()) });
};
src/routes/tags.js
// 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

ReturnResult
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:

Response examples
return res.status(201).headers({ "x-created": "yes" }).json({ id: 7 });
Response examples
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:

src/routes/avatars/[id].ts
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>);
};
src/routes/avatars/[id].js
// 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 to application/octet-stream. Set a different content type and any caching headers with res.headers().
  • Response options: Status, headers, and cookies apply to streams as for any other result.
  • HEAD requests: A HEAD request to a streamed route receives headers without the body.
  • Text bodies: Use res.text() for strings.
  • Range and ETag: Goribu adds no range or ETag handling. 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:

src/routes/old-home.ts
import type { Handler } from "goribu/server";

// Move an old URL to its replacement.
export const GET: Handler = (req, res) => {
  return res.status(301).redirect("/");
};
src/routes/old-home.js
// 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.
src/routes/visits.ts
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 });
};
src/routes/visits.js
// 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 or null.
  • Set options: req.cookies.set() accepts path, domain, expires, maxAge, httpOnly, secure and sameSite.
  • 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:

src/routes/posts/_middleware.ts
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;
src/routes/posts/_middleware.js
// 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 no next().
  • 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:

src/routes/dashboard/_middleware.ts
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;
src/routes/dashboard/_middleware.js
// 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 req last for one request.
  • Field names: Use your own names, such as user or team. Do not overwrite Goribu's fields, such as method, path, cookies, or env.

In TypeScript, augment AppRequestFields once. Every Handler and Middleware then accepts those fields:

src/request.d.ts
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.js works normally.
  • Browser module cleanup: Before building a route's browser module, Goribu removes its HTTP handlers, meta, prerender, and uploads exports, 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 direct eval are 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 use export *.
src/routes/report.ts
// Name each re-exported handler; export * is rejected in route files.
export { load as GET } from "../lib/report.server";
src/routes/report.js
// 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.