Request handlers

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

updated in 0.9.4
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(). It always returns an object, built according to 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.
Empty body, text/* or other typesAn empty object. In development, a non-empty body Goribu can't parse logs a warning.

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();

  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: req.body() is typed Record<string, unknown>, so you can destructure fields without a cast. Each field is unknown until you check it, as above.
  • Text bodies: text/plain and other text types give {}. To read the text, decode the raw bytes: new TextDecoder().decode(await req.rawBody()).
  • 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();
  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()) });
};

Loading data in parallel

When a page needs several reads that don't depend on each other, start them together with Promise.all. The page then waits for the slowest read instead of all of them in turn:

Inside a GET handler
// Both queries only need the URL, so neither waits for the other.
const [post, comments] = await Promise.all([
  req.d1<Post>`
    SELECT id, title, body FROM posts WHERE id = ${req.params.id}
  `.get(),
  req.d1<Comment>`
    SELECT id, author, body FROM comments WHERE post_id = ${req.params.id} ORDER BY id
  `.all(),
]);

if (!post) return res.notFound();
return res.render({ post, comments });
Inside a GET handler
// Both queries only need the URL, so neither waits for the other.
const [post, comments] = await Promise.all([
  req.d1`
    SELECT id, title, body FROM posts WHERE id = ${req.params.id}
  `.get(),
  req.d1`
    SELECT id, author, body FROM comments WHERE post_id = ${req.params.id} ORDER BY id
  `.all(),
]);

if (!post) return res.notFound();
return res.render({ post, comments });
  • Dependent reads: When a query needs another query's result, await that one first, then start the rest together.
  • Failures: Promise.all rejects when any read fails, and the request ends in the error page, the same as awaiting each read in turn.
  • Any read works: Mix req.d1, req.kv, req.cache and fetch() calls in the same Promise.all.
  • Writes: Promise.all is not a transaction. For writes that must succeed together, use atomic batches.

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.
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.

Background work

Pass a promise to req.waitUntil to finish work after the response is sent, such as analytics, logs, or cache fills. Handlers and middleware can both call it:

Inside a GET handler
// Count the view without making the visitor wait for it.
req.waitUntil(
  req.d1`UPDATE posts SET views = views + 1 WHERE id = ${post.id}`
    .run()
    .catch((error) => console.error("View count failed", error)),
);

return res.render({ post });
Inside a GET handler
// Count the view without making the visitor wait for it.
req.waitUntil(
  req.d1`UPDATE posts SET views = views + 1 WHERE id = ${post.id}`
    .run()
    .catch((error) => console.error("View count failed", error)),
);

return res.render({ post });
  • Pass the promise: Start the work and pass the promise it returns. Don't await it first, and don't pass a function. req.waitUntil throws for anything that isn't a promise.
  • Catch failures: The visitor never sees a background failure. Catch and log errors, as above.
  • Time limit: Cloudflare keeps the Worker running for up to 30 seconds after the response. See waitUntil.
  • No retries: Work that fails or runs out of time is lost. Send work that must happen to a queue through a Wrangler binding.
  • Required writes: Await writes that must succeed before telling the user their change was saved.
  • D1 freshness: A D1 write that finishes after the response isn't covered by the visitor's own-writes guarantee, so their next page may not show it yet.

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";