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:
import type { Handler } from "goribu/server";
import { Form } from "goribu";
type Props = { query: string; results: string[] };
// Filter sample data using the current query string.
export const GET: Handler<Props> = (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 }: Props) {
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>
);
}
import { Form } from "goribu";
// Filter sample data using the current query string.
export const 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>
);
}
- Input refresh: The form key refreshes the uncontrolled input when the URL changes, including Back and Forward.
- Stored search data: Replace the sample list with a parameterized D1 query when search needs stored data.
Set a preference cookie
Use a POST handler to validate a preference, set a cookie, and redirect. Read the cookie again in GET:
import type { Handler } from "goribu/server";
import { Form } from "goribu";
type Props = { density: "compact" | "comfortable" };
// Normalize the stored preference before passing it to the page.
export const GET: Handler<Props> = (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 }: Props) {
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 const POST: Handler<Props> = async (req, res) => {
const { density } = await req.body();
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");
};
import { Form } from "goribu";
// Normalize the stored preference before passing it to the page.
export const 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 const POST = async (req, res) => {
const { density } = await req.body();
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.
Keep a page static and its action dynamic
A page with no handlers can remain static while its Form submits elsewhere:
import { Form } from "goribu";
type Result = { message: string };
// The initial page needs no request-specific props.
export default function CheckNamePage() {
return (
<Form<Result> 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>
);
}
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>
);
}
import type { Handler } from "goribu/server";
// Return data to the existing form rather than rendering a different route.
export const POST: Handler = async (req, res) => {
const { name } = await req.body();
if (typeof name !== "string" || !name.trim()) {
return res.invalid({ name: "Enter a name." });
}
return res.json({ message: `Hello, ${name.trim()}.` });
};
// Return data to the existing form rather than rendering a different route.
export const POST = async (req, res) => {
const { name } = await req.body();
if (typeof name !== "string" || !name.trim()) {
return res.invalid({ name: "Enter a name." });
}
return res.json({ message: `Hello, ${name.trim()}.` });
};
- In-place results: Return JSON from the separate action to update the existing form.
- Other screens: Return a redirect from the action to navigate to another screen.
- Render restriction: A separate action cannot use enhanced
res.render()to replace a page at a different pathname. - Before hydration: The example's native POST displays the returned JSON as a document.
Show form errors in a toast
onResult runs after each submission. Pass the form-level error, errors.root, to a toast, and keep field errors next to their inputs:
import { useEffect, useState } from "react";
import { Form } from "goribu";
import type { Handler } from "goribu/server";
export const POST: Handler = async (req, res) => {
const { email } = await req.body();
if (typeof email !== "string" || !email.includes("@")) {
return res.invalid({ email: "Enter an email address." });
}
// A string is a form-level error: it becomes errors.root.
if (email.endsWith("@example.com")) {
return res.invalid("Example addresses can't subscribe.");
}
return res.json({ subscribed: true });
};
export default function SubscribePage() {
const [toast, setToast] = useState<string | null>(null);
// Hide the toast after four seconds.
useEffect(() => {
if (!toast) return;
const timer = setTimeout(() => setToast(null), 4000);
return () => clearTimeout(timer);
}, [toast]);
return (
<>
<Form
method="post"
onResult={(result) => {
if (!result.ok && result.errors.root) setToast(result.errors.root);
}}
>
{({ pending, errors }) => (
<>
<label>Email <input name="email" aria-describedby="email-error" /></label>
<p id="email-error">{errors?.email}</p>
<button disabled={pending}>Subscribe</button>
</>
)}
</Form>
{/* The live region stays mounted so screen readers announce new messages. */}
<div role="status" aria-live="polite">
{toast && <p className="toast">{toast}</p>}
</div>
</>
);
}
import { useEffect, useState } from "react";
import { Form } from "goribu";
export const POST = async (req, res) => {
const { email } = await req.body();
if (typeof email !== "string" || !email.includes("@")) {
return res.invalid({ email: "Enter an email address." });
}
// A string is a form-level error: it becomes errors.root.
if (email.endsWith("@example.com")) {
return res.invalid("Example addresses can't subscribe.");
}
return res.json({ subscribed: true });
};
export default function SubscribePage() {
const [toast, setToast] = useState(null);
// Hide the toast after four seconds.
useEffect(() => {
if (!toast) return;
const timer = setTimeout(() => setToast(null), 4000);
return () => clearTimeout(timer);
}, [toast]);
return (
<>
<Form
method="post"
onResult={(result) => {
if (!result.ok && result.errors.root) setToast(result.errors.root);
}}
>
{({ pending, errors }) => (
<>
<label>Email <input name="email" aria-describedby="email-error" /></label>
<p id="email-error">{errors?.email}</p>
<button disabled={pending}>Subscribe</button>
</>
)}
</Form>
{/* The live region stays mounted so screen readers announce new messages. */}
<div role="status" aria-live="polite">
{toast && <p className="toast">{toast}</p>}
</div>
</>
);
}
.toast {
position: fixed;
right: 1rem;
bottom: 1rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
background: #b91c1c;
color: white;
}
- Also covers failures:
errors.rootis also set when the request fails or the handler returns a non-2xxres.json(), with a generic message. That response's JSON is inresult.dataif you'd rather show your own message. - Success toasts: Check
result.okin the same callback, for example to confirm a save. - Crashes: If the handler throws, Goribu loads your error page after
onResult, so the toast doesn't stay on screen. - Toast libraries: To use a toast library, call it from
onResultin place ofsetToast.
Allow cross-origin requests (CORS)
Goribu doesn't add CORS headers, and there is no special CORS export. To let another site call your API, return the headers from responseHeaders:
import { defineConfig } from "goribu/server";
const allowed = new Set(["https://app.example.com"]);
export default defineConfig({
name: "my-app",
compatibilityDate: "2026-09-01",
responseHeaders({ request }) {
const origin = request.headers.get("origin");
if (!allowed.has(origin)) return {};
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, POST",
"Access-Control-Allow-Headers": "content-type",
"Vary": "Origin",
};
},
});
- Preflight included: Goribu answers
OPTIONSfor every route on its own, and these headers are added to that answer too, so you don't need anOPTIONShandler. - Only some paths: Check
new URL(request.url).pathnamein the function, for example to allow only/api/. - Not in middleware: Middleware doesn't run for the automatic
OPTIONSanswer, so CORS set there fails the browser's preflight. - Not on static pages: Prerendered pages are served without the Worker and get no
responseHeaders. Serve cross-origin data from a handler.
CORS for one route
To open a single endpoint instead of the whole app, set the headers in the route itself. Export OPTIONS to answer the browser's preflight, and add the same headers to every other handler:
import type { Handler } from "goribu/server";
const allowed = new Set(["https://app.example.com"]);
// Headers for an allowed origin; other origins get none and the browser blocks them.
function cors(origin: string | null): Record<string, string> {
if (!origin || !allowed.has(origin)) return {};
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, POST",
"Access-Control-Allow-Headers": "content-type",
"Vary": "Origin",
};
}
// The preflight only needs the headers, so the body is empty.
export const OPTIONS: Handler = (req, res) =>
res.headers(cors(req.headers.get("origin"))).text("");
export const GET: Handler = (req, res) =>
res.headers(cors(req.headers.get("origin"))).json({ items: [] });
export const POST: Handler = async (req, res) => {
const item = await req.body();
return res.status(201).headers(cors(req.headers.get("origin"))).json({ item });
};
const allowed = new Set(["https://app.example.com"]);
// Headers for an allowed origin; other origins get none and the browser blocks them.
function cors(origin) {
if (!origin || !allowed.has(origin)) return {};
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Methods": "GET, POST",
"Access-Control-Allow-Headers": "content-type",
"Vary": "Origin",
};
}
// The preflight only needs the headers, so the body is empty.
export const OPTIONS = (req, res) =>
res.headers(cors(req.headers.get("origin"))).text("");
export const GET = (req, res) =>
res.headers(cors(req.headers.get("origin"))).json({ items: [] });
export const POST = async (req, res) => {
const item = await req.body();
return res.status(201).headers(cors(req.headers.get("origin"))).json({ item });
};
- Every handler: The preflight only lets the request through. The browser still rejects the real response unless it carries
Access-Control-Allow-Origintoo. - Shared helper: If several routes need the same rules, move
cors()into a.serverfile outsidesrc/routes/and import it. - Status 200: The preflight answers 200 with an empty body, which browsers accept like 204.
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:
import { ClientEntry, Stylesheet } from "goribu";
import type { DocumentProps } 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 }: DocumentProps) {
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/setup/client-entry.js" nonce={nonce} />
</body>
</html>
);
}
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/setup/client-entry.js" nonce={nonce} />
</body>
</html>
);
}
- Development exclusion:
import.meta.env.PRODleaves analytics out ofpnpm dev. - Production inclusion: Production builds include the analytics snippet on pre-rendered and Worker-rendered pages.
- Public identifier: The measurement ID is public. No server binding or secret is needed.
- Navigation tracking: Goribu changes browser history between pages. In the GA4 web stream, open Enhanced measurement → Page views → Show advanced settings and enable Page changes based on browser history events alongside page-load tracking.
- Page-load-only behavior: Without history-event tracking, this snippet covers only full page loads.
- Duplicate events: Keep the automatic setup as the single source of page views. Adding manual
page_viewevents would double-count visits. - Analytics verification: With debug mode enabled, verify a direct visit, a Link transition, and Back/Forward in Google's DebugView.
- Expected event: Each page change should produce one
page_viewwith the expected URL, title, and referrer. See Google's single-page application guide for setup and 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:
import type { Handler } from "goribu/server";
type Message = { name: string; message: string };
type Snapshot = { messages: Message[]; cachedAt: string };
// Serve a snapshot where a short delay in visibility is acceptable.
export const GET: Handler = async (req, res) => {
try {
const cached = await req.kv.get<Snapshot>("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<Message>`
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);
};
// Serve a snapshot where a short delay in visibility is acceptable.
export const GET = async (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);
};
- Stale public data: This endpoint deliberately allows stale public data. Keep the submitter's confirmation page on default D1 queries rather than reading it through KV.
- Failure handling: Catch only the optional cache failure. An authoritative database failure should still fail the request.