Skip to content
Recipes
RecipesRecipes
On this page

Recipes

Small patterns you can adapt without introducing another layer of application machinery.

Search with a shareable URL

Let a GET form put the search term in the URL. The route handler reads it and returns page props:

src/routes/search.jsx
import { Form } from "goribu";

// Filter sample data using the current query string.
export function GET(req, res) {
  const query = req.query.get("q")?.trim() ?? "";
  const items = ["React", "Cloudflare Workers", "D1", "KV"];
  const results = items.filter((item) => item.toLowerCase().includes(query.toLowerCase()));
  return res.render({ query, results });
}

// A GET form keeps the search usable through ordinary browser navigation.
export default function SearchPage({ query, results }) {
  return (
    <main>
      <h1>Search</h1>
      <Form method="get" key={query}>
        <label>Search <input name="q" defaultValue={query} /></label>
        <button>Search</button>
      </Form>
      <ul>{results.map((item) => <li key={item}>{item}</li>)}</ul>
    </main>
  );
}

The form key refreshes the uncontrolled input when the URL changes, including Back and Forward. Replace the sample list with a parameterized D1 query when the search needs stored data.

Use a POST handler to validate a preference, set a cookie, and redirect. Read the cookie again in GET:

src/routes/preferences.jsx
import { Form } from "goribu";

// Normalize the stored preference before passing it to the page.
export function GET(req, res) {
  const density = req.cookies.get("density") === "compact" ? "compact" : "comfortable";
  return res.render({ density });
}

// A redirect refreshes these props after the preference is saved.
export default function PreferencesPage({ density }) {
  return (
    <Form method="post" key={density}>
      <label>
        Density
        <select name="density" defaultValue={density}>
          <option value="comfortable">Comfortable</option>
          <option value="compact">Compact</option>
        </select>
      </label>
      <button>Save</button>
    </Form>
  );
}

// Accept only the supported values, regardless of body encoding.
export async function POST(req, res) {
  const body = await req.body();
  const density = body instanceof FormData ? body.get("density") : body.density;
  if (density !== "compact" && density !== "comfortable") {
    return res.invalid({ density: "Choose a supported density." });
  }
  req.cookies.set("density", density, {
    path: "/", maxAge: 2592000, httpOnly: true, sameSite: "Lax",
  });
  return res.redirect("/preferences");
}

This stores a presentation preference, not an identity or authorization decision. Sensitive state needs server-side verification.

Cache public data with KV

Read authoritative data from D1 and use KV only as an optional cache. This example assumes the tutorial's feedback table and both resources are configured:

src/routes/public-feedback.js
// Serve a snapshot where a short delay in visibility is acceptable.
export async function GET(req, res) {
  try {
    const cached = await req.kv.get("public:feedback:v1", { type: "json" });
    if (cached !== null) return res.json(cached);
  } catch {
    console.warn("Feedback cache read failed; using D1.");
  }

  const messages = await req.d1.public`
    SELECT name, message FROM feedback ORDER BY id DESC LIMIT 10
  `.all();
  const snapshot = { messages, cachedAt: new Date().toISOString() };
  try {
    await req.kv.put("public:feedback:v1", JSON.stringify(snapshot), {
      expirationTtl: 300,
    });
  } catch {
    console.warn("Feedback cache write failed; returning the database result.");
  }
  return res.json(snapshot);
}

This endpoint deliberately allows stale public data. Keep the submitter's confirmation page on default D1 queries rather than reading it through KV. Catch only the optional cache failure; an authoritative database failure should still fail the request.

Keep a page static and its action dynamic

A page with no handlers can remain static while its Form submits elsewhere:

src/routes/check-name.jsx
import { Form } from "goribu";

// The initial page needs no request-specific props.
export default function CheckNamePage() {
  return (
    <Form method="post" action="/check-name-result">
      {({ pending, errors, data }) => (
        <>
          <label>Name <input name="name" /></label>
          <p>{errors?.name}</p>
          <button disabled={pending}>Check name</button>
          <p>{data?.message}</p>
        </>
      )}
    </Form>
  );
}
src/routes/check-name-result.js
// Return data to the existing form rather than rendering a different route.
export async function POST(req, res) {
  const body = await req.body();
  const name = body instanceof FormData ? body.get("name") : body.name;
  if (typeof name !== "string" || !name.trim()) {
    return res.invalid({ name: "Enter a name." });
  }
  return res.json({ message: `Hello, ${name.trim()}.` });
}

Use JSON for an in-place result, or redirect for another screen. The action cannot use enhanced res.render() to replace a page at a different pathname. Before hydration, this POST displays the returned JSON as a document.

Add Google Analytics

Add the Google tag to your Document so it loads once on a direct visit and stays in place during client-side navigation. Merge the analytics code into your existing Document, keeping your fonts, theme script, and other shared tags. Replace G-XXXXXXXXXX with your GA4 web stream's measurement ID:

src/routes/_document.jsx
import { ClientEntry, Stylesheet } from "goribu";

const measurementId = "G-XXXXXXXXXX";
const analyticsScript = `
  window.dataLayer = window.dataLayer || [];
  function gtag() { window.dataLayer.push(arguments); }
  gtag("js", new Date());
  gtag("config", ${JSON.stringify(measurementId)});
`;

// Load the Google tag once per document, outside the hydrated page root.
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}
        {import.meta.env.PROD && (
          <>
            <script
              async
              nonce={nonce}
              src={`https://www.googletagmanager.com/gtag/js?id=${measurementId}`}
            />
            <script nonce={nonce} dangerouslySetInnerHTML={{ __html: analyticsScript }} />
          </>
        )}
      </head>
      <body>
        <div id="root">{children}</div>
        <ClientEntry src="/src/client-entry.js" nonce={nonce} />
      </body>
    </html>
  );
}

The import.meta.env.PROD check leaves analytics out of pnpm dev. Production builds include it on both pre-rendered and Worker-rendered pages. The measurement ID is public; no server binding or secret is needed.

Goribu changes browser history when navigating between pages. In your GA4 web stream, open Enhanced measurement → Page views → Show advanced settings and enable Page changes based on browser history events alongside page-load tracking. Without that setting, this snippet only covers full page loads. Keep this automatic setup as the single source of page views; adding manual page_view events would double-count visits.

Verify a direct visit, a Link transition, and Back/Forward in Google's DebugView with debug mode enabled. Check that each page change produces one page_view with the expected URL, title, and referrer. See Google's single-page application guide for the setup and verification steps.