# Goribu > A full-stack React framework for Cloudflare Workers, built for humans and coding agents. --- # Introduction Source: https://goribu.dev/docs Goribu is a full-stack React framework for Cloudflare Workers. The Workers stack is not only one of the most powerful and complete ones out there, but also very attractive cost-wise. Building with it is straightforward, but if you come from Next.js, Express.js, or Remix v2, you have to adopt a lot of new conventions. So Goribu aims to make it more familiar and delightful to build production apps there. To achieve this, the key decisions were: - **Choosing the happy path for building apps,** which means simplicity and speed of change over complex optimizations before it’s needed. Your code should be elegant, even if you are not writing it anymore. - **Making what the platform offers as easy as possible to use,** without configuring the same things over and over. (Global D1 read replicas, free CDN, etc.) - **Adjusting to the new AI coding paradigm** and targeting to solve new challenges like understanding the code while allowing your agents to code and provision resources. ## How? Simplicity doesn’t mean not modern or less production-ready. You get a server-driven app architecture with the initial load and SEO benefits, combined with app-like navigation for the next requests. But without client-side caches to mutate, skeleton UI or RSC. Together with your React page components being in the same file as their handlers, you can add or improve your app’s features without having to change a bunch of files. ## Human / AI Fit While this started as a way for humans to write code, its architecture makes it really great for coding with AI agents: - **The API surface is minimal** compared to most popular frameworks so it’s very lightweight in the context of each session. - **There is one canonical way to do things,** so what you would write is what your AI will write too. As a resut, you can always check your code and know what is happening. - **The ability to provision resources with a single entries** in your config, without having to use the dashboard means your coding agent can do it too for you. All of this makes AI coding with Goribu a token-efficient and smooth experience, that works great with Codex, Claude Code and other AI agents. If you are looking for a new way to create applications on Cloudflare, go ahead and give it a try to see for yourself. --- # At a glance Source: https://goribu.dev/docs/at-a-glance A one-page overview of the basics for every part of Goribu, with examples for developers and coding agents. ## 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: **src/routes/posts/\[id\].tsx** ```tsx 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 = (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 (

{post.title}

{post.body}

); } ``` **src/routes/posts/\[id\].jsx** ```jsx // 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 (

{post.title}

{post.body}

); } ``` - **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](/docs/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: **Terminal** ```sh pnpm create goribu@latest my-app cd my-app pnpm install pnpm dev ``` **Terminal** ```sh 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](/docs/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 typecheck` before 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 type` for types such as `Handler` and `Middleware` from `goribu/server`. - **Config language:** Configuration stays in `goribu.config.js` in both JavaScript and TypeScript projects. See [Installation](/docs/installation#create-an-app) for starter options and [the API reference](/docs/api-reference) for available types. ## Routes - **Route location:** Routes are files inside `src/routes/`. A file's path determines its URL. - **Index routes:** An `index` file serves its containing folder. - **Dynamic segments:** Square brackets capture a dynamic parameter. | File | URL | | --- | --- | | `src/routes/index.tsx` | `/` | | `src/routes/about.tsx` | `/about` | | `src/routes/posts/index.tsx` | `/posts` | | `src/routes/posts/[id].tsx` | `/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](/docs/routing) for route matching and supported file patterns. ## Forms and req.body `
` submits to the current route's `POST` handler. Its render child exposes submission state: **src/routes/greet.tsx** ```tsx import type { Handler } from "goribu/server"; import { Form } from "goribu"; type Result = { greeting: string }; export default function Greet() { return ( method="post"> {({ pending, errors, data }) => ( <>

{errors?.name}

{errors?.root}

{data?.greeting}

)} ); } export const POST: Handler = async (req, res) => { const { name } = await req.body() as Record; if (typeof name !== "string" || !name.trim()) { return res.invalid({ name: "Enter your name." }); } return res.json({ greeting: `Hello, ${name.trim()}!` }); }; ``` **src/routes/greet.jsx** ```jsx import { Form } from "goribu"; export default function Greet() { return (
{({ pending, errors, data }) => ( <>

{errors?.name}

{errors?.root}

{data?.greeting}

)}
); } 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 `method` to `get`, `post`, `put`, `patch`, or `delete` to call the matching route handler. - **Submission state:** `Form` exposes `pending`, `errors`, and `data`. 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 `File` values, 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 to `data`. - **Redirect result:** Return `res.redirect("/posts")` after a successful submission to navigate to another page. - **Form history:** Add `replace` to `
` to replace the current history entry on navigation, so Back skips pages such as a completed login form. Read [Forms](/docs/forms) for submission behavior and [Request handlers](/docs/handlers#reading-the-body) for body parsing. ## Client navigation Use `Link` for client-side navigation. For the post route above: **TSX** ```tsx import { Link } from "goribu"; // Navigate without a full page reload after hydration. export default function HomePage() { return Read the first post; } ``` **JSX** ```jsx import { Link } from "goribu"; // Navigate without a full page reload after hydration. export default function HomePage() { return Read the first post; } ``` - **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 `Chart` when switching views should replace the current history entry, so Back leaves the screen instead of retracing view changes. - **UI imports:** Import `Link`, `NavLink` and `navigate` from `goribu`. `NavLink` adds active state for menus. Use `isActive` for styling and `end` for an exact pathname match: **Active links** ```tsx import { NavLink } from "goribu"; ``` **Active links** ```jsx import { NavLink } from "goribu"; ``` Use `navigate()` when navigation follows an event without a link. You can also refresh a Worker-rendered page after a mutation: **Event navigation** ```ts import { navigate } from "goribu"; export async function onSetupComplete() { await navigate("/dashboard", { replace: true }); } export async function onDataSaved() { await navigate(location.href); } ``` **Event navigation** ```js import { navigate } from "goribu"; export async function onSetupComplete() { await navigate("/dashboard", { replace: true }); } export async function onDataSaved() { await navigate(location.href); } ``` See [Links & navigation](/docs/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: **Static metadata** ```tsx 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
About us
; } ``` **Static metadata** ```jsx 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
About us
; } ``` 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: **Dynamic metadata** ```tsx import type { Meta } from "goribu"; type Props = { post: { title: string } }; export const meta: Meta = ({ props: { post } }) => ({ title: post.title, description: `Read ${post.title} on our blog.`, }); ``` **Dynamic metadata** ```jsx export const meta = ({ props: { post } }) => ({ title: post.title, description: `Read ${post.title} on our blog.`, }); ``` See [Rendering](/docs/rendering#page-metadata) for metadata options and document setup. ## Styling - **Tailwind:** The starter has Tailwind configured so utility classes work immediately. - **Plain CSS:** Edit `src/styles/global.css` or import CSS from a page or component. - **Style delivery:** Goribu delivers the current page's styles, including on client navigation. See [Styling and assets](/docs/rendering#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` 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: **src/routes/posts/\_middleware.ts** ```ts import type { Middleware } from "goribu/server"; const requestId: Middleware = (req, res) => { res.headers({ "x-request-id": crypto.randomUUID() }); }; export default requestId; ``` **src/routes/posts/\_middleware.js** ```js const requestId = (req, res) => { res.headers({ "x-request-id": crypto.randomUUID() }); }; export default requestId; ``` - **Middleware context:** Middleware receives the same `req` and `res` as 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 as `res.redirect("/login")`, responds immediately and stops the chain. - **Pass data:** You can attach values to `req`, for example `req.user = user` and 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](/docs/handlers#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: **src/routes/visits.ts** ```ts 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 }); }; ``` **src/routes/visits.js** ```js 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 `/visits` and reload to see the counter increase. - **Cookie reads:** `get()` returns a string or `null` for a missing cookie. - **Cookie options:** Options are explicit. In this example, `path: "/"` makes the cookie app-wide and `maxAge` keeps 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](/docs/handlers#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.ts` outside `src/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.tsx` for missing pages and `_500.tsx` for 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](/docs/handlers#server-only-work) for private helpers and [Errors](/docs/errors) for custom error pages. ## Environment variables For local development, put your values in `.env` at the project root, then run `pnpm dev`: **.env** ```dotenv 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 with `PUBLIC_` 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`: **.env.production** ```dotenv 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](https://developers.cloudflare.com/workers/configuration/secrets/). 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](/docs/configuration#environment-variables) for file overrides and [production secrets](/docs/configuration#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: **goribu.config.js** ```js 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](/docs/configuration#resources) 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: **Inside a request handler** ```ts 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` SELECT id, title FROM posts WHERE id = ${lastRowId} `.get(); const posts = await req.d1`SELECT id, title FROM posts`.all(); ``` **Inside a request handler** ```js 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 or `null`. - **Row list:** `.all()` returns an array of rows. - **Scalar result:** `.value()` returns the first column of the first row or `null`. 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](/docs/d1) for queries and freshness options, and [Migrations](/docs/d1#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: **src/routes/snapshot.ts** ```ts import type { Handler } from "goribu/server"; type Snapshot = { createdAt: string }; export const GET: Handler = 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); }; ``` **src/routes/snapshot.js** ```js 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](/docs/kv) 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: **Terminal** ```sh 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: **Terminal** ```sh pnpm check pnpm run deploy ``` - **Check plan:** `check` shows the target Worker, resources and migrations without making changes. - **Deploy sequence:** `deploy` builds, 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 deploy` again 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](/docs/deployment) for Cloudflare setup, deployment checks, and schema changes. --- # Installation Source: https://goribu.dev/docs/installation Start with a minimal React app that runs locally on the Workers runtime. ## Prerequisites - **Local tools:** Use Node.js 22 or newer and pnpm. - **Cloudflare account:** An account is needed only for deployment. Local development works without a login. You don't need a paid Workers plan to build and deploy with Goribu. ## Create an app **Terminal** ```sh pnpm create goribu@latest my-app cd my-app pnpm install pnpm dev ``` **Terminal** ```sh pnpm create goribu@latest my-app --js cd my-app pnpm install pnpm dev ``` Open the local URL printed in your terminal. The starter is ready for you. - **App name:** Use up to 50 lowercase letters, digits or hyphens. Start with a letter and end with a letter or digit. Pass a name, not a path. - **Existing directories:** The starter generator does not overwrite an existing directory. - **Dependency installation:** The starter generator does not install dependencies for you. For JavaScript, add `--js`: **Terminal** ```sh pnpm create goribu@latest my-app --js ``` Both variants use the same framework APIs. ## Find your way around | File | Purpose | | --- | --- | | `src/routes/index.tsx` | The home page. | | `public/` | Static assets served at the site root. | | `src/setup/_document.tsx` | Optional custom HTML document around your pages. | | `src/setup/client-entry.js` | Required browser bootstrap and global CSS imports. | | `src/styles/global.css` | Global styles and the Tailwind import. | | `goribu.config.js` | App runtime, deployment, and resource settings. | | `vite.config.js` | Vite, Goribu, and Tailwind plugins. | | `AGENTS.md` | Project instructions for coding agents. | Your pages and handlers go in the `src/routes` together, while reusable components can go in other folders like `src/components/`. Leave `.goribu/`, `.wrangler/`, `goribu.wrangler.jsonc`, and `node_modules/` to the tools that generate them. ## Start coding with your agent Your agents will read the included `AGENTS.md` before changing the app. If not, just ask them to. Then describe a feature and its expected behavior. The [AI coding guide](/docs/agents) explains how to provide useful context and [At a glance](/docs/at-a-glance) there's a compact API introduction to point your agents to. ## Commands Run commands from your app directory. | Command | Purpose | | --- | --- | | `pnpm dev` | Develop locally with hot updates. | | `pnpm typecheck` | Check TypeScript without emitting files. TypeScript starter only. | | `pnpm build` | Build the Worker, browser assets, and static pages. | | `pnpm check` | Inspect the production target, resources, and migrations without changing them. | | `pnpm exec goribu cloudflare-login` | Log in to Cloudflare through Wrangler. | | `pnpm run deploy` | Build and deploy to Cloudflare. | - **Custom port:** Run `pnpm dev --port 5199` to use a specific port number (in this case 5199) instead of the default. The command fails if the port is already in use. - **Network access:** Add `--host` to your `pnpm dev` to listen on every network interface. Then other devices on your network can reach the development server. - **Persistent defaults:** Set development defaults in `vite.config.js`. **Alternative development commands** ```sh pnpm dev --port 5199 pnpm dev --host ``` If using npm, add `--` before script options so npm forwards them to the development server: `npm run dev -- --host` or `npm run dev -- --port 5199`. Development and production builds compile TypeScript without type checking. Run `pnpm typecheck` separately in TypeScript projects. Use the full **`pnpm run deploy`** command (don't forget the `run` part) to ship your app to Cloudflare. See [Deploys](/docs/deployment) when you are ready to publish. ## Write a route Create `src/routes/hello.tsx` with the following example, then visit `/hello?name=Kate`. Use type-only imports to describe your page props and request handlers: **src/routes/hello.tsx** ```tsx import type { Handler } from "goribu/server"; import type { Meta } from "goribu"; type Props = { name: string }; // Read the query string and pass serializable props to the page. export const GET: Handler = (req, res) => { const name = req.query.get("name")?.trim() || "friend"; return res.render({ name }); }; export const meta: Meta = ({ props }) => ({ title: `Hello, ${props.name}`, }); // Render the same props on the server and in the browser. export default function HelloPage({ name }: Props) { return

Hello, {name}.

; } ``` **src/routes/hello.jsx** ```jsx // Read the query string and pass serializable props to the page. export const GET = (req, res) => { const name = req.query.get("name")?.trim() || "friend"; return res.render({ name }); }; export const meta = ({ props }) => ({ title: `Hello, ${props.name}`, }); // Render the same props on the server and in the browser. export default function HelloPage({ name }) { return

Hello, {name}.

; } ``` - **Handler generics:** `Handler` also accepts types for Worker bindings and dynamic parameters. - **Runtime validation:** Types describe expectations, so validate request input at runtime. The [API reference](/docs/api-reference#typescript) lists public types. --- # AI coding Source: https://goribu.dev/docs/agents Your agent will get up to speed automatically so you only have to provide a clear description of what the app should do. ## Start with the project instructions - **Project instructions:** The starter's `AGENTS.md` covers routes, handlers, forms, resources and project commands. If your tool doesn't automatically load it, ask it to read it before working. Most modern harnesses load it by default. - **One source of guidance:** If your tool uses another instructions file, point it to `AGENTS.md`. - **Framework overview:** [At a glance](/docs/at-a-glance) gives a practical overview of Goribu you can also point when there is confusion. - **Documentation context:** Use **Copy page as Markdown** on any docs page to bring its contents into a chat or coding session. ## Describe the behavior A useful request names the feature, its data and what success looks like: `Add a feedback page where visitors enter a name and message. Store submissions in D1, show validation errors beside the fields and redirect to the feedback list after saving. Add a migration for the table.` This maps directly to a route, a React page, a handler, a database declaration and a migration. The agent can prepare those files. Goribu supplies local resources during development and creates or reuses production resources on deployment. For an existing feature, include its route and describe the current and desired behavior. A reproducible error is more useful than “fix the form.” ## Add your app's rules Keep a short section in `AGENTS.md` for decisions specific to your app: **AGENTS.md** ```md ## App conventions - Use pnpm for package commands. - Keep shared UI in src/components/. - Feedback is plain text, with a maximum of 2,000 characters. - Validate writes in server handlers. - Run the relevant checks and pnpm build before handoff. ``` Update these rules when the app changes. When upgrading Goribu, review the starter's instructions and merge useful changes with your own rules or ask your agent to do it for you. ## Review the result - **Change review:** Ask the agent to explain the changed behavior and the checks it ran. - **Form checks:** Check success, validation errors, and pending state. - **Database checks:** Inspect both the query and its migration. **Terminal** ```sh pnpm typecheck pnpm build pnpm check ``` **Terminal** ```sh pnpm build pnpm check ``` - **JavaScript projects:** Skip `pnpm typecheck` in JavaScript projects. - **Read-only plan:** `pnpm check` inspects the production plan without actually changing resources. Schema migrations and Worker deployments are separate operations. A successful build does not mean that a destructive migration is safe for existing data. See [D1 migrations](/docs/d1#migrations). ## Working in a chat - **Code context:** If the assistant cannot read the project (is web-based, etc), provide the relevant route and related helpers. - **Behavior context:** Include the error or desired behavior and matching docs. - **Project context:** Include `AGENTS.md` for conventions and configuration or middleware when they affect the request. Never paste credentials or local environment files. Describe the required variable names and use placeholder values instead. --- # Tutorial Source: https://goribu.dev/docs/tutorial Build a small feedback app with a D1 table, a form, validation and a list of saved messages. It will be a single page where visitors leave a name and a message and everyone sees the latest entries. It's tiny, but along the way you'll use every core piece of Goribu: - **Routes and prerendering:** how a file becomes a page and when that page is built ahead of time. - **Styling:** where global CSS lives, with a ready-made stylesheet you can copy and retheme. - **D1 and migrations:** a real database, locally and in production. - **Forms and validation:** saving input safely and showing helpful errors. - **Deployment:** shipping it all to Cloudflare. It takes about 15 minutes. You'll need Node.js 22+ and pnpm. A Cloudflare account is only needed for the very last step, if you want to deploy it. ## Create the app Use the default TypeScript starter so the examples below can be pasted directly: **Terminal** ```sh pnpm create goribu@latest guestbook cd guestbook && pnpm install && pnpm dev ``` **Terminal** ```sh pnpm create goribu@latest guestbook --js cd guestbook && pnpm install && pnpm dev ``` Open the URL that `pnpm dev` prints and you'll see the starter's welcome page. Leave the dev server running, and from now on, every save will show up in the browser right away. Before changing anything, here are the only files we'll touch: | File | What it's for | | --- | --- | | `src/routes/index.tsx` | The page at `/`. Every file in `src/routes/` becomes a route. | | `src/styles/global.css` | App-wide CSS, already imported by `src/setup/client-entry.js`. | | `goribu.config.js` | App configuration, including databases. | | `migrations/` | SQL files that shape your database. We'll create this soon. | ## Make it yours The starter page has done its job, so let's replace it. Open `src/routes/index.tsx` and swap its contents for a first version of the guestbook, with a couple of hardcoded messages for now: **src/routes/index.tsx** ```tsx export const meta = { title: "Guestbook" }; type Message = { id: number; name: string; message: string }; // Placeholder data until the database arrives. const messages: Message[] = [ { id: 2, name: "TJ", message: "Lovely little site!" }, { id: 1, name: "Ryan", message: "First!" }, ]; export default function GuestbookPage() { return (

Guestbook

Say hello and leave a note for the next visitor.

Recent messages

    {messages.map((item) => (
  • {item.name}

    {item.message}

  • ))}
); } ``` **src/routes/index.jsx** ```jsx export const meta = { title: "Guestbook" }; // Placeholder data until the database arrives. const messages = [ { id: 2, name: "TJ", message: "Lovely little site!" }, { id: 1, name: "Ryan", message: "First!" }, ]; export default function GuestbookPage() { return (

Guestbook

Say hello and leave a note for the next visitor.

Recent messages

    {messages.map((item) => (
  • {item.name}

    {item.message}

  • ))}
); } ``` Save and the browser will show your guestbook instead of the welcome page. The `meta` export sets the tab title. Notice what this page *doesn't* have: no handler, no middleware. That means, that for now, Goribu **prerenders** it, turning it into plain HTML at build time and serving it as a static asset. ## Add the style Let's make it look good before going further. The starter already uses Tailwind, so we'll keep it and put it to work. Replace **everything** in `src/styles/global.css` with the stylesheet below. It includes the Tailwind import, so you can paste the whole thing without breaking anything: **src/styles/global.css** ```css @import "tailwindcss"; /* Your palette. Each --color-* also becomes a Tailwind class, like bg-accent or text-muted. */ @theme { --color-page: #f8fafc; --color-surface: #ffffff; --color-ink: #172033; --color-muted: #64748b; --color-line: #cbd5e1; --color-accent: #4f46e5; --color-on-accent: #ffffff; --color-danger: #b91c1c; } /* Dark mode only swaps the values; every class keeps working. */ @media (prefers-color-scheme: dark) { :root { --color-page: #0f172a; --color-surface: #1e293b; --color-ink: #e2e8f0; --color-muted: #94a3b8; --color-line: #334155; --color-accent: #818cf8; --color-on-accent: #0f172a; --color-danger: #f87171; } } @layer base { body { @apply bg-page text-ink font-sans leading-normal; } } /* Small named classes built from Tailwind utilities keep the JSX readable. */ @layer components { .guestbook { @apply mx-auto w-full max-w-2xl px-4 py-12; } .guestbook header p { @apply mt-0 text-muted; } .guestbook form { @apply my-8 grid gap-2 rounded-xl border border-line bg-surface p-6; } .guestbook label { @apply font-semibold; } .guestbook input, .guestbook textarea { @apply w-full rounded-lg border border-line bg-page p-3 text-ink; } .guestbook textarea { @apply min-h-28 resize-y; } .guestbook :is(input, textarea, button) { @apply focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-accent; } .guestbook button { @apply mt-2 cursor-pointer justify-self-start rounded-lg bg-accent px-5 py-3 font-semibold text-on-accent disabled:cursor-wait disabled:opacity-60; } .error { @apply mb-2 text-sm text-danger empty:hidden; } .messages { @apply list-none p-0; } .messages li { @apply border-b border-line py-4 wrap-anywhere; } .messages li p { @apply mt-1 mb-0 whitespace-pre-wrap; } .empty { @apply text-muted; } } ``` A few things are worth knowing here: - **`@theme` is your palette.** Change `--color-accent` and the button, focus rings and links all follow. Because these are Tailwind theme values, you can also use them directly in JSX, like `className="text-accent"`. - **`@apply` builds named classes from utilities.** The page uses short names like `guestbook` and `messages`, and the styling details stay in one place. Prefer utilities directly in your markup? That works too. Mix the two however you like. - **Dark mode comes free.** The media query only swaps color values, so nothing else has to change. - **No Tailwind in your app?** Tailwind is part of this app's Vite setup, not part of Goribu. If you removed it, drop the `@import` line and translate each `@apply` line into plain CSS. ## Declare the database Hardcoded messages won't get us far, so let's add a real database. We'll use Cloudflare's D1 for that. In `goribu.config.js`, add the line below to the existing configuration, keeping the starter's other lines: **Configuration field** ```js database: { type: "d1", name: "guestbook-db" } ``` That line, inside `defineConfig({ ... })`, is enough for Goribu to manage a local database now and the production one later. ## Create the table A database needs a table and tables are created with migrations: small SQL files that run in order, so your local and production databases always end up with the same shape. These are necessary only when your db schema changes. Let's generate the first one: **Terminal** ```sh pnpm exec goribu migrate:new create messages ``` Replace the generated file's SQL with: **migrations/0001\_create-messages.sql** ```sql -- migrate:up CREATE TABLE messages ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, message TEXT NOT NULL, created_at TEXT NOT NULL ); -- migrate:down DROP TABLE messages; ``` Then apply it to your local database: **Terminal** ```sh pnpm exec goribu migrate ``` The `down` section drops the table and its data. Ignore it for now, as it's mainly there for development and to make the migration format explicit, not as a routine undo step. Let's put a first message in so the page has something to show. `goribu d1` runs SQL against the same local database `pnpm dev` uses: **Terminal** ```sh pnpm exec goribu d1 "INSERT INTO messages (name, message, created_at) VALUES ('Goribu', 'Welcome to the guestbook!', datetime('now'))" ``` ## Load real messages Time to swap the placeholder array for the database. A page reads its data in a `GET` handler which runs on the server and passes props to the page through `res.render()`. Update the route: **src/routes/index.tsx** ```tsx import type { Handler } from "goribu/server"; export const meta = { title: "Guestbook" }; type Message = { id: number; name: string; message: string }; type Props = { messages: Message[] }; // Load the newest messages on every request. export const GET: Handler = async (req, res) => { const messages = await req.d1` SELECT id, name, message FROM messages ORDER BY id DESC LIMIT 50 `.all(); return res.render({ messages }); }; export default function GuestbookPage({ messages }: Props) { return (

Guestbook

Say hello and leave a note for the next visitor.

Recent messages

{messages.length ? (
    {messages.map((item) => (
  • {item.name}

    {item.message}

  • ))}
) : (

No messages yet. Be the first!

)}
); } ``` **src/routes/index.jsx** ```jsx export const meta = { title: "Guestbook" }; // Load the newest messages on every request. export const GET = async (req, res) => { const messages = await req.d1` SELECT id, name, message FROM messages ORDER BY id DESC LIMIT 50 `.all(); return res.render({ messages }); }; export default function GuestbookPage({ messages }) { return (

Guestbook

Say hello and leave a note for the next visitor.

Recent messages

{messages.length ? (
    {messages.map((item) => (
  • {item.name}

    {item.message}

  • ))}
) : (

No messages yet. Be the first!

)}
); } ``` Refresh and the welcome message from the database appears. Remember that the page was prerendered? Adding a `GET` handler means this page is no longer built ahead of time. It now renders on the Worker for **every request**, which is exactly what we want for data that keeps changing. There's no extra setting to flip: the handler is the signal. The `req.d1` template tag turns `${...}` values into bound parameters, so values never get pasted into the SQL string. You'll see that in the next step. ## Accept new messages Now let's give visitors the ability to write. Add Goribu's `Form` to the page and a `POST` handler next to `GET`. Start simple, with no validation yet: **src/routes/index.tsx** ```tsx import type { Handler } from "goribu/server"; import { Form } from "goribu"; // ...meta, types and GET stay the same... // The server part that handles the form export const POST: Handler = async (req, res) => { const fields = await req.body() as Record; await req.d1` INSERT INTO messages (name, message, created_at) VALUES (${String(fields.name)}, ${String(fields.message)}, ${new Date().toISOString()}) `.run(); return res.redirect("/"); }; ``` **src/routes/index.jsx** ```jsx import { Form } from "goribu"; // ...meta, types and GET stay the same... // The server part that handles the form export const POST = async (req, res) => { const fields = await req.body(); await req.d1` INSERT INTO messages (name, message, created_at) VALUES (${String(fields.name)}, ${String(fields.message)}, ${new Date().toISOString()}) `.run(); return res.redirect("/"); }; ``` And in the page component between the header and the list: **src/routes/index.tsx** ```tsx // The React form {({ pending }) => ( <>