Rendering

Send useful HTML first, then hydrate the page for interaction and navigation.

On this page

First visit and navigation

  • Direct visits: For a Worker-served page, Goribu runs middleware and the GET handler, resolves metadata and streams HTML through the Document.
  • Page props: A handler's res.render(props) supplies props for both the page and its browser hydration.

After hydration, eligible Link transitions request a JSON description of the next page and its data. Goribu loads the page module and its styles, updates metadata and commits the navigation. You keep writing ordinary React components and handlers.

In production, pre-rendered pages receive both HTML and client navigation data from Static Assets without invoking the Worker. They still hydrate normally. See Routes for the conditions that make a page static.

Page metadata

Export meta from a route. Use an object for fixed values:

src/routes/about.tsx
export const meta = {
  title: "About our team",
  description: "Meet the people building this app.",
  canonical: "https://example.com/about",
  robots: "index,follow",
};

// Page content lives inside the shared Document.
export default function AboutPage() {
  return <main><h1>About our team</h1></main>;
}
src/routes/about.jsx
export const meta = {
  title: "About our team",
  description: "Meet the people building this app.",
  canonical: "https://example.com/about",
  robots: "index,follow",
};

// Page content lives inside the shared Document.
export default function AboutPage() {
  return <main><h1>About our team</h1></main>;
}

Use a synchronous function when metadata depends on page props or the URL. Load data in the handler, then pass it through res.render():

Dynamic metadata
import type { Meta } from "goribu";

type Props = { post: { title: string; summary: string } };

// Add this export to a route that renders a post prop.
export const meta: Meta<Props> = ({ props, url }) => ({
  title: props.post.title,
  description: props.post.summary,
  canonical: new URL(url.pathname, "https://example.com").href,
  meta: [{ property: "og:type", content: "article" }],
});
Dynamic metadata
// Add this export to a route that renders a post prop.
export const meta = ({ props, url }) => ({
  title: props.post.title,
  description: props.post.summary,
  canonical: new URL(url.pathname, "https://example.com").href,
  meta: [{ property: "og:type", content: "article" }],
});
  • Function contract: A meta function receives { props, url } and must be synchronous. Do not make it async.
  • Validation: Metadata is validated before HTML streaming begins.
  • Navigation: Route-owned head tags are replaced on navigation.
  • Pre-rendered metadata: Props, URL, and metadata are captured at build time. Client navigation reuses that metadata even when query strings change.
  • Shared head tags: Keep shared tags, such as the viewport and favicon, in the Document.
  • Metadata fields: Supported fields are title, description, canonical, robots, meta, and links. See the reference.
  • Additional tags: Use meta for extra name/property tags and links for additional link tags.
  • Duplicate tags: Do not repeat a canonical or description in both the dedicated field and the additional tags.

The Document

The starter supplies an optional Document in src/setup/. To customize it, edit src/setup/_document.tsxsrc/setup/_document.jsx:

src/setup/_document.tsx
import { ClientEntry, Stylesheet } from "goribu";
import type { DocumentProps } from "goribu";

// Preserve the framework's head, hydration root, and browser entry.
export default function Document({ children, head, nonce }: DocumentProps) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Stylesheet />
        {head}
      </head>
      <body>
        <div id="root">{children}</div>
        <ClientEntry src="/src/setup/client-entry.js" nonce={nonce} />
      </body>
    </html>
  );
}
src/setup/_document.jsx
import { ClientEntry, Stylesheet } from "goribu";

// Preserve the framework's head, hydration root, and browser entry.
export default function Document({ children, head, nonce }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Stylesheet />
        {head}
      </head>
      <body>
        <div id="root">{children}</div>
        <ClientEntry src="/src/setup/client-entry.js" nonce={nonce} />
      </body>
    </html>
  );
}
  • Default shell: Goribu uses its default shell when no custom Document is present.
  • Required browser entry: src/setup/client-entry.js is always required.
  • Document structure: Keep one Stylesheet, one ClientEntry, and the fixed #root.
  • Worker nonce: nonce is available for your own inline scripts on Worker-rendered responses, including static pages rendered by the Worker in development.
  • Pre-rendered nonce: nonce is undefined on prerendered pages, which receive no CSP header. Set prerender = false on a page that must have a CSP.
  • Inline scripts: Pass nonce={nonce} to your inline scripts. React omits an undefined attribute, so one Document works for Worker-rendered and prerendered pages.
  • CSP configuration: Send a CSP through responseHeaders. A <meta> tag built from nonce would block a prerendered page's inline scripts. See Configuration.
  • Entry URL: ClientEntry's src is the development fallback. Production resolves the hashed asset.

Styling and assets

The starter includes Tailwind by default. Plain CSS works too:

src/setup/client-entry.js
import "goribu/client";
import "../styles/global.css";
  • CSS imports: Import CSS from src/setup/client-entry.js, a page or a component.
  • Style loading: Goribu includes styles needed for the first render and loads the next page's styles during navigation.
  • Fixed asset URLs: Put files in public/ when they need a fixed URL. For example public/logo.svg is available at /logo.svg.
  • Imported assets: Vite-managed imports are built into the asset graph.
  • Reserved paths: Do not create files under /_goribu/ or public HTML that shadows a route.

Server and browser code

Read a browser-only API after hydration so the initial server and browser output match. This component checks the connection once when it mounts:

src/components/connection-check.tsx
import { useEffect, useState } from "react";

export default function ConnectionCheck() {
  const [online, setOnline] = useState<boolean | null>(null);

  // After React has taken over
  useEffect(() => {
    setOnline(navigator.onLine);
  }, []);

  let message = "Checking…";
  if (online !== null) message = online ? "Online" : "Offline";

  return <p>{message}</p>;
}
src/components/connection-check.jsx
import { useEffect, useState } from "react";

export default function ConnectionCheck() {
  const [online, setOnline] = useState(null);

  // After React has taken over
  useEffect(() => {
    setOnline(navigator.onLine);
  }, []);

  let message = "Checking…";
  if (online !== null) message = online ? "Online" : "Offline";

  return <p>{message}</p>;
}
  • Execution environments: Page components run on the server and in the browser.
  • Browser APIs: Use and read browser-only APIs in effects or event handlers.
  • Request-specific work: Keep request-specific work in handlers.

Anything serialized into page props is public. Keep credentials and private SDKs in server-only modules, and return only the fields the UI needs.