Serving pages
A Goribu app deploys one Worker plus static assets. A route's handlers run on the server and its default export is the React page. Here's an example with some sample data:
import type { Handler } from "goribu/server";
type Post = { id: string; title: string; body: string };
type Props = { post: Post };
// The server function that runs and passes props to the page
export const GET: Handler<Props> = (req, res) => {
const posts = [
{ id: "42", title: "Hello Goribu", body: "Your first page." },
];
const post = posts.find((post) => post.id === req.params.id);
if (!post) return res.notFound();
return res.render({ post });
};
// The React page component
export default function PostPage({ post }: Props) {
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
);
}
// The server function that runs and passes props to the page
export const GET = (req, res) => {
const posts = [
{ id: "42", title: "Hello Goribu", body: "Your first page." },
];
const post = posts.find((post) => post.id === req.params.id);
if (!post) return res.notFound();
return res.render({ post });
};
// The React page component
export default function PostPage({ post }) {
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
);
}
- Initial request: Dynamic pages stream HTML on the first visit.
- Client navigation: After hydration, navigation fetches JSON to update the page and its data.
- Static candidates: A page without HTTP handlers becomes static automatically if its URL is known at build time, no middleware applies to it, and it has not opted out explicitly.
- Static delivery: Successful pre-renders use Static Assets and are served by the CDN, for free. They also still hydrate and can be interactive.
- API-only routes: Export handlers without a React page component to create an API-only route.
- Prebuilt 404: Unknown URLs also receive a prebuilt 404 page from Static Assets, so scanners probing missing files do not invoke the Worker.
Read Rendering for static pages, request-time rendering, and hydration.
CLI basics
Use Node.js 22+ and pnpm. Create an app, then install its dependencies from the new directory:
pnpm create goribu@latest my-app
cd my-app
pnpm install
pnpm dev
pnpm create goribu@latest my-app --js
cd my-app
pnpm install
pnpm dev
| Command | What it does |
|---|---|
pnpm create-goribu my-app | Creates a new Goribu TS app. |
pnpm dev | Starts the local development server. |
pnpm exec goribu cloudflare-login | Logs in to Cloudflare through Wrangler. |
pnpm run deploy | Builds and deploys the app to Cloudflare. |
See Installation for setup and the full command list.
TypeScript
- Default language: New Goribu apps use TypeScript with strict type checking configured.
- JavaScript starter: You can create a JavaScript project with
pnpm create goribu@latest my-app --js. - Type checking: Run
pnpm typecheckbefore deploying TypeScript apps. Development and production builds compile TypeScript without running the type checker automatically. - Public types: Goribu includes types for handlers, middleware, metadata and forms. Use
import typefor types such asHandlerandMiddlewarefromgoribu/server. - Config language: Configuration stays in
goribu.config.jsin both JavaScript and TypeScript projects.
See Installation for starter options and the API reference for available types.
Routes
- Route location: Routes are files inside
src/routes/. A file's path determines its URL. - Index routes: An
indexfile serves its containing folder. - Dynamic segments: Square brackets capture a dynamic parameter.
| File | URL |
|---|---|
src/routes/index.tsxsrc/routes/index.jsx | / |
src/routes/about.tsxsrc/routes/about.jsx | /about |
src/routes/posts/index.tsxsrc/routes/posts/index.jsx | /posts |
src/routes/posts/[id].tsxsrc/routes/posts/[id].jsx | /posts/42, with req.params.id equal to "42" |
- File extensions: Routes support
.js,.jsx,.ts, and.tsx. - Path parameters: Read dynamic segments from
req.params. - Query parameters: Read query strings with
req.query.get("name").
See Routes for route matching and supported file patterns.
Forms and req.body
<Form method="post"> submits to the current route's POST handler. Its render child exposes submission state:
import type { Handler } from "goribu/server";
import { Form } from "goribu";
type Result = { greeting: string };
export default function Greet() {
return (
<Form<Result> method="post">
{({ pending, errors, data }) => (
<>
<label>Name <input name="name" /></label>
<p>{errors?.name}</p>
<p>{errors?.root}</p>
<button disabled={pending}>Greet me</button>
<p>{data?.greeting}</p>
</>
)}
</Form>
);
}
export const POST: Handler = async (req, res) => {
const { name } = await req.body() as Record<string, unknown>;
if (typeof name !== "string" || !name.trim()) {
return res.invalid({ name: "Enter your name." });
}
return res.json({ greeting: `Hello, ${name.trim()}!` });
};
import { Form } from "goribu";
export default function Greet() {
return (
<Form method="post">
{({ pending, errors, data }) => (
<>
<label>Name <input name="name" /></label>
<p>{errors?.name}</p>
<p>{errors?.root}</p>
<button disabled={pending}>Greet me</button>
<p>{data?.greeting}</p>
</>
)}
</Form>
);
}
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({ greeting: `Hello, ${name.trim()}!` });
};
- Form methods: Set
methodtoget,post,put,patch, ordeleteto call the matching route handler. - Submission state:
Formexposespending,errors, anddata. After hydration, submissions update the form without reloading the page. - Body parsing: Always await
req.body(). It parses JSON requests and form fields. - Form fields: Both form encodings return field objects. Uploads are
Filevalues, repeated names become arrays and missing fields are absent. Validate field values. - Validation result: Return
res.invalid()to expose errors keyed by field name. - JSON result: Return
res.json()to pass a successful result todata. - Redirect result: Return
res.redirect("/posts")after a successful submission to navigate to another page. - Form history: Add
replaceto<Form>to replace the current history entry on navigation, so Back skips pages such as a completed login form.
Read Forms for submission behavior and Request handlers for body parsing.
Client navigation
Use Link for client-side navigation. For the post route above:
import { Link } from "goribu";
// Navigate without a full page reload after hydration.
export default function HomePage() {
return <Link href="/posts/42">Read the first post</Link>;
}
import { Link } from "goribu";
// Navigate without a full page reload after hydration.
export default function HomePage() {
return <Link href="/posts/42">Read the first post</Link>;
}
- Link prefetch: Your app's internal links prefetch on intent, like on hover over or press of a link, by default. Set
prefetch={false}to disable it. - Link history: Use
<Link href="/reports?view=chart" replace>Chart</Link>when switching views should replace the current history entry, so Back leaves the screen instead of retracing view changes. - UI imports: Import
Link,NavLinkandnavigatefromgoribu.
NavLink adds active state for menus. Use isActive for styling and end for an exact pathname match:
import { NavLink } from "goribu";
<nav>
<NavLink href="/" end>Home</NavLink>
<NavLink
href="/posts"
className={({ isActive }) => isActive ? "active" : undefined}
>
Posts
</NavLink>
<NavLink href="/settings" end>Settings</NavLink>
</nav>
import { NavLink } from "goribu";
<nav>
<NavLink href="/" end>Home</NavLink>
<NavLink
href="/posts"
className={({ isActive }) => isActive ? "active" : undefined}
>
Posts
</NavLink>
<NavLink href="/settings" end>Settings</NavLink>
</nav>
Use navigate() when navigation follows an event without a link. You can also refresh a Worker-rendered page after a mutation:
import { navigate } from "goribu";
export async function onSetupComplete() {
await navigate("/dashboard", { replace: true });
}
export async function onDataSaved() {
await navigate(location.href);
}
import { navigate } from "goribu";
export async function onSetupComplete() {
await navigate("/dashboard", { replace: true });
}
export async function onDataSaved() {
await navigate(location.href);
}
See Links & navigation for active links, history, and page refreshes.
Metadata
Each page can set its own metadata through a meta export. For shared document tags, like the viewport and favicon, use _document. Page metadata can be a plain object:
export const meta = {
title: "About",
description: "How our team builds on Cloudflare.",
canonical: "https://example.com/about",
robots: "index,follow",
};
export default function AboutPage() {
return <main>About us</main>;
}
export const meta = {
title: "About",
description: "How our team builds on Cloudflare.",
canonical: "https://example.com/about",
robots: "index,follow",
};
export default function AboutPage() {
return <main>About us</main>;
}
Metadata can also be a synchronous function receiving the same props as the page and its URL. For a post route, like the one earlier, add:
import type { Meta } from "goribu";
type Props = { post: { title: string } };
export const meta: Meta<Props> = ({ props: { post } }) => ({
title: post.title,
description: `Read ${post.title} on our blog.`,
});
export const meta = ({ props: { post } }) => ({
title: post.title,
description: `Read ${post.title} on our blog.`,
});
See Rendering for metadata options and document setup.
Styling
- Tailwind: The starter has Tailwind configured so utility classes work immediately.
- Plain CSS: Edit
src/styles/global.cssor import CSS from a page or component. - Style delivery: Goribu delivers the current page's styles, including on client navigation.
See Styling and assets for CSS, Tailwind, and public files.
Middleware
Middleware runs on the server before the requested route's handler or page. Default-export a function from _middleware.ts_middleware.js to apply it to routes in that folder and its subfolders. For example, this adds a request ID header to responses from the posts routes:
import type { Middleware } from "goribu/server";
const requestId: Middleware = (req, res) => {
res.headers({ "x-request-id": crypto.randomUUID() });
};
export default requestId;
const requestId = (req, res) => {
res.headers({ "x-request-id": crypto.randomUUID() });
};
export default requestId;
- Middleware context: Middleware receives the same
reqandresas the route and may be async. - Continue: Return nothing from middleware to continue to the route. There is no
next(). - Stop: Returning a
res.*result, such asres.redirect("/login"), responds immediately and stops the chain. - Pass data: You can attach values to
req, for examplereq.user = userand then read them in the handler. - Middleware order: Parent-folder middleware runs first.
- Rendering impact: Middleware keeps all affected pages on the Worker. Put it in the folder that needs it so you don't disable pre-rendering for static pages.
See Middleware for scope, execution order, and passing request data.
Cookies
Use req.cookies to read, set and delete cookies in handlers or middleware. Goribu adds cookie changes to the returned response automatically, including redirects. This route keeps a counter in the browser's cookies:
import type { Handler } from "goribu/server";
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 });
};
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 });
};
- Cookie example: Visit
/visitsand reload to see the counter increase. - Cookie reads:
get()returns a string ornullfor a missing cookie. - Cookie options: Options are explicit. In this example,
path: "/"makes the cookie app-wide andmaxAgekeeps it for a day. - Cookie deletion: Call
req.cookies.delete("visits", { path: "/" })from a handler or middleware. Match the path and domain that was used when setting it, if either was specified.
See Cookies for cookie methods and options.
Server code and errors
- Page environments: Page components run on the server and in the browser.
- Private helpers: Keep helpers such as
src/lib/posts.server.tssrc/lib/posts.server.jsoutsidesrc/routes/and import them from handlers or middleware. - Server boundary: For better protection, Goribu rejects browser code importing a
.server.*module. - Public props: Anything passed through
res.render()reaches the browser. Pass only data the page should expose. - Background work: Call
req.waitUntil(promise)in handlers or middleware for work that may continue after the response, such as logging or cache updates. - Missing resource: If a resource is not found, return
res.notFound(). - Error pages: Customize
src/routes/_404.tsxsrc/routes/_404.jsxfor missing pages and_500.tsx_500.jsxfor unhandled production errors. - Error diagnostics: Development shows details for errors, while production logs the error with an ID and passes the ID to the error page.
Read Server-only work for private helpers and Errors for custom error pages.
Environment variables
For local development, put your values in .env at the project root, then run pnpm dev:
PUBLIC_API_URL=https://api.example.com
STRIPE_KEY=local-test-key
- In handlers or middleware: Read the private key with
req.env.STRIPE_KEY. - In browser code: Read the public URL with
import.meta.env.PUBLIC_API_URL. Only values prefixed withPUBLIC_are available this way. They are included in the browser bundle, so never use this prefix for actual secrets.
For production, put your production values in .env.production before running pnpm run deploy:
PUBLIC_API_URL=https://api.example.com
STRIPE_KEY=your-production-key
Deployment uploads the file's values as Worker secrets. Your code still reads server values through req.env. Public values are also embedded during the build.
If you prefer to manage server secrets in Cloudflare, add them to your Worker's Settings > Variables and Secrets, choosing the Secret type. Goribu preserves existing secrets omitted from .env.production; without that file, it uses the secrets already on the Worker. See Cloudflare's secret setup instructions.
Browser values must be available when you build. Changing a Worker secret in the dashboard does not update import.meta.env.PUBLIC_* in an existing browser bundle; set the public value for the build and redeploy.
Keep real environment files out of Git. Local server values from .env are not uploaded to production.
See Configuration for file overrides and production secrets for deployment details.
Cloudflare resources configuration
Declare a D1 database, a KV namespace, or both in the existing config. Keep your app's name and compatibility date:
import { defineConfig } from "goribu/server";
export default defineConfig({
name: "my-app",
compatibilityDate: "2026-08-31",
database: { type: "d1", name: "my-app-db" },
kv: { name: "my-app-kv" },
});
- Local resources: Restart development after changing bindings. Goribu supplies local stores for development.
- Production resources: When you deploy, Goribu reuses or creates named production resources. Local data stays separate.
- Generated bindings: Goribu generates Wrangler bindings. No resource IDs or separate Wrangler config are needed.
See Configuration for resource options.
D1 database
D1 is Cloudflare's SQLite database. With database configured above, use req.d1 in handlers or middleware. Create your tables with migrations before querying them. This example assumes a posts table with id and title columns:
type Post = { id: number; title: string };
const title = "Hello Goribu";
const { lastRowId } = await req.d1`
INSERT INTO posts (title) VALUES (${title})
`.run();
if (lastRowId === undefined) throw new Error("Missing inserted row ID.");
const post = await req.d1<Post>`
SELECT id, title FROM posts WHERE id = ${lastRowId}
`.get();
const posts = await req.d1<Post>`SELECT id, title FROM posts`.all();
const title = "Hello Goribu";
const { lastRowId } = await req.d1`
INSERT INTO posts (title) VALUES (${title})
`.run();
if (lastRowId === undefined) throw new Error("Missing inserted row ID.");
const post = await req.d1`
SELECT id, title FROM posts WHERE id = ${lastRowId}
`.get();
const posts = await req.d1`SELECT id, title FROM posts`.all();
- SQL parameters: Values inside
${...}become bound SQL parameters, so input values do not require building SQL strings. - D1 writes:
.run()executes inserts, updates, and deletes and returns{ changes, lastRowId }. - Single row:
.get()returns one row ornull. - Row list:
.all()returns an array of rows. - Scalar result:
.value()returns the first column of the first row ornull.
For schema changes, run pnpm exec goribu migrate:new "describe your change" and add your SQL under -- migrate:up in the generated file. Run pnpm exec goribu migrate to apply pending migrations locally. Deployment applies pending production migrations automatically. Keep applied files unchanged and create a new migration for each later change.
Read Database: D1 for queries and freshness options, and Migrations for the schema workflow.
KV store
KV stores values by key, without tables or migrations. Use it for cached results or shared configuration when briefly stale reads are acceptable. With kv configured above, access it through req.kv in handlers or middleware.
This route reads a cached timestamp. If it is missing, it creates and stores one with a five-minute lifetime:
import type { Handler } from "goribu/server";
type Snapshot = { createdAt: string };
export const GET: Handler = async (req, res) => {
let snapshot = await req.kv.get<Snapshot>("snapshot", { type: "json" });
if (snapshot === null) {
snapshot = { createdAt: new Date().toISOString() };
await req.kv.put("snapshot", JSON.stringify(snapshot), {
expirationTtl: 300,
});
}
return res.json(snapshot);
};
export const GET = async (req, res) => {
let snapshot = await req.kv.get("snapshot", { type: "json" });
if (snapshot === null) {
snapshot = { createdAt: new Date().toISOString() };
await req.kv.put("snapshot", JSON.stringify(snapshot), {
expirationTtl: 300,
});
}
return res.json(snapshot);
};
Visit /snapshot to try it. { type: "json" } parses the stored JSON; without that option, get() returns text. Missing keys return null. To store an object, use JSON.stringify() as above. expirationTtl is measured in seconds.
To remove a value, use await req.kv.delete("snapshot"). To browse keys, use await req.kv.list(), which returns one page at a time. Await writes and deletes before reporting success.
Reads can temporarily return an older value or null even after a successful write, and deleted values can remain visible briefly. The example returns the value it already has instead of reading it back. Use D1 for exact counters or flows that must reliably show a visitor their own changes.
See KV store for storage options, pagination, and consistency details.
Going live
Set your app's Worker name in goribu.config.js. With a Cloudflare account, log in from the app directory:
pnpm exec goribu cloudflare-login
Goribu uses its installed Wrangler to open the browser authorization flow. Wrangler stores the credentials for later runs.
Then preview the production plan and deploy:
pnpm check
pnpm run deploy
- Check plan:
checkshows the target Worker, resources and migrations without making changes. - Deploy sequence:
deploybuilds, creates or reuses D1/KV resources, applies pending production migrations and uploads the Worker and static assets. It prints the public URL. - Later releases: Run
pnpm run deployagain to update the same Worker. Existing databases and KV namespaces are reused while local data is not uploaded. - Schema compatibility: Migrations run before Worker upload. Schema changes must also work with the version currently serving requests.
Follow Deploys for Cloudflare setup, deployment checks, and schema changes.