Reference

Rendering

Rendering starts in a handler. It loads what the page needs, then calls res.render(Component, props). The same call serves two audiences: on a first visit it streams HTML and on an in-app navigation it returns a small JSON payload the client uses to swap the page. One handler for both jobs, which is why a Goribu app needs no separate API layer to feed its own pages.

// src/routes/posts/[id].jsx
export async function GET(req, res) {
  const post = await req.d1.get("SELECT * FROM posts WHERE id = ?", [req.params.id]);
  if (!post) return res.notFound();
  return res.render(PostPage, { post });
}

export default function PostPage({ post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  );
}

res.render takes the component reference and props. res.render(PostPage, { post }), never res.render(<PostPage />). The page is a pure function of those props.

The Document

The Document is the HTML shell wrapped around every page. It owns <html>, <head>, <body>, global stylesheets, app-wide metadata and the client entry that makes the page interactive.

// src/document.jsx
import { Stylesheet, ClientEntry, ReactRefresh } from "goribu";

export default function Document({ children }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="icon" href="/favicon.ico" />
        <Stylesheet href="src/app.css" />
        <ReactRefresh />
      </head>
      <body>
        <main id="root">{children}</main>
        <ClientEntry src="src/client-entry.js" />
      </body>
    </html>
  );
}

You pass it to Goribu in src/server.jsx. Without one, Goribu falls back to a minimal built-in shell. Most apps keep their own so the shell is explicit.

Document helpers

  • <Stylesheet href> — links a stylesheet from your source tree and resolves it to the hashed asset URL in production.
  • <ClientEntry src> — loads your browser entry. This is what enables hydration, <Link>, <Form> and app-like navigation. Without it pages still render as HTML and native links and forms still work, but none of Goribu's client behavior runs.
  • <ReactRefresh> — renders the Fast Refresh preamble in development and nothing in production. Use it instead of hand-rolling the Vite preamble.

Hydration

On the first visit the server sends HTML. Then the browser loads the client entry, hydrates the markup and makes it interactive. The entry is usually one line:

// src/client-entry.js
import "goribu/client";

Keep goribu/client first. Add browser-only setup like analytics, error reporting, after it.

Page metadata

Title and description do not live in the component tree. A route declares them with a meta export, which is a plain object for static metadata, or a function when it depends on the props:

// static
export const meta = {
  title: "About · My App",
  description: "Why we built this.",
};

// derived from props
export function meta({ props }) {
  return { title: `${props.post.title} · Blog`, description: props.post.excerpt };
}

There is no <Head> component or hook. The meta export is the metadata API. Every field is optional:

Field Renders
title <title>
description <meta name="description">
canonical <link rel="canonical">
robots <meta name="robots">
og Open Graph tags (flat object, keyed without the og: prefix)
twitter Twitter card tags (flat object)
meta array of custom <meta> tags, for repeated properties
links array of custom <link> tags
export const meta = {
  title: "Goribu",
  canonical: "https://goribu.dev/",
  og: { title: "Goribu", image: "https://goribu.dev/og.jpg", type: "website" },
  twitter: { card: "summary_large_image" },
  meta: [
    { property: "og:image", content: "https://example.com/a.jpg" },
    { property: "og:image", content: "https://example.com/b.jpg" },
  ],
};

On a full-page load the metadata is rendered into <head> during SSR. On client navigation Goribu swaps the managed head entries for the new route and leaves your Document's app-wide entries alone. Give every page route its own title.

Going deeper

SPA navigation

After hydration, clicking a Goribu <Link> navigates without a full reload. The same route handler still runs but only the response format changes:

First visit       → streamed HTML
Client navigation → JSON navigation payload

Under the hood the client fetches the payload from a framework endpoint (/_goribu/worker-nav), which re-dispatches to your route with an X-Goribu header set. res.render sees it and returns the component name, props and metadata as JSON instead of HTML. The client resolves the component and swaps it in. You never write a separate route to support this, as the GET you already have serves both.

Rendering from mutations

res.render is method-agnostic, but GET is where pages usually render. For POST and other mutations, prefer res.json when the current page should apply the result in place and res.redirect when the next state is another page. Reserve a full re-render (e.g. res.status(400).render(...)) for showing validation errors. See Forms.

Layouts

Goribu has no layout primitive. Shared navigation, footers and shells are plain React components you import into the routes that use them, opting for composition, not another routing convention.

import { AppLayout } from "@/components/AppLayout";

export default function Dashboard({ user }) {
  return <AppLayout><h1>Welcome, {user.name}</h1></AppLayout>;
}

Prerendering

A route whose GET reads no request data, like no req.d1, req.env, req.headers, body, query or params, is prerendered to static HTML at build time and served straight from Cloudflare's CDN. The Worker never runs for it.

A dynamic route can opt in by exporting prerender with the parameter values to bake. For a single dynamic segment, export an array of strings. For multiple pages, an array of objects keyed by segment name or a function (sync or async) that computes the list:

// src/routes/docs/[slug].jsx
export const prerender = ["routing", "rendering", "request-and-response"];

// src/routes/[org]/[repo].jsx
export const prerender = [
  { org: "goribu", repo: "goribu" },
  { org: "cloudflare", repo: "workers-sdk" },
];

// computed at build time
export async function prerender() {
  const rows = await loadAllDocs();
  return rows.map((row) => row.slug);
}

Inside a prerendering GET you may await, fetch and read req.params, req.query, req.d1 and req.postgres. These are resolved per value at build time. Reading the environment, request headers or the body (req.env, req.headers, req.body and friends) in a route that also exports prerender is a build error, because none of those exist at build time. Listed values are baked into static files; values not in the list still reach the GET handler at runtime, so the route can render them dynamically or return a 404.