Start

10-min Tutorial

To see Goribu in action, let's build a real personal website. A page with an email signup form, backed by a Cloudflare D1 database and then shipped to the edge. Every step is something you'd actually do.

The recommended way to start is create-goribu. New projects are TypeScript by default, but we'll use JavaScript here to keep the code focused on the framework, so we'll pass the --js flag.

1. Create your project

npx create-goribu my-site --js
cd my-site
npm install
npm run dev

create-goribu scaffolds the project but does not install for you, so npm install is its own step. Then npm run dev starts the dev server. Open http://localhost:5173 and you'll see the starter page.

pnpm users: Cloudflare's Wrangler has build scripts pnpm blocks by default. After pnpm install, run pnpm approve-builds once and approve them.

2. Make it yours

Now we can start the actual coding. The page you just saw lives at src/routes/index.jsx, so open it up and make it yours.

// src/routes/index.jsx
export const meta = {
  title: "Aggelos",
  description: "Aggelos's personal website.",
};

const NAME = "Aggelos";

export default function Home() {
  return (
    <main className="site-page">
      <h1>Hello, I'm {NAME} and this is my website.</h1>
      <p>
        Here you'll learn a little about who I am, what I'm building, and the
        things I care about. Stick around and sign up to the newsletter if you'd
        like to follow along.
      </p>
    </main>
  );
}

export function GET(req, res) {
  return res.render(Home, {});
}

Two exports do the work: Home is the page and the minimal GET handler hands it to the browser on every visit. (Without that GET there's nothing to render for the request, so the page won't load.) The meta export gives Goribu the page title and description.

The starter ships Tailwind 4 wired through src/app.css. We'll keep the JSX focused on content and behavior by moving the styling into semantic class names and @apply rules:

/* src/app.css */
@import "tailwindcss";

@theme {
  --font-sans: "InterVariable", "Inter", ui-sans-serif, system-ui, sans-serif;
}

:root {
  font-family: Inter, sans-serif;
  font-feature-settings:
    "liga" 1,
    "calt" 1;
  color-scheme: light dark;
}
@supports (font-variation-settings: normal) {
  :root {
    font-family: InterVariable, sans-serif;
  }
}

body {
  background-color: #fff;
  color: #121416;
}

.site-page {
  @apply mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-4 px-6 py-16;
}

.site-page h1 {
  @apply text-2xl font-medium tracking-tight;
}

.site-page h1 span {
  @apply font-medium;
}

.site-page p {
  @apply text-sm leading-relaxed text-gray-500 dark:text-gray-400;
}

.subscribe-form {
  @apply flex flex-col gap-2;
}

.subscribe-form input {
  @apply w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100;
}

.subscribe-form button {
  @apply rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500;
}

.site-page .form-error {
  @apply text-red-600;
}

.home-link {
  @apply text-sm font-medium text-indigo-600 transition hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300;
}

@media (prefers-color-scheme: dark) {
  body {
    background-color: #0a0a0a;
    color: #ededed;
  }
}

Save a file and the browser updates instantly. Title and description come from the meta export and Goribu puts them in the <head> on the server, keeping them in sync as you navigate.

3. Add a database

We'll let visitors subscribe for email updates from you and store them in D1, Cloudflare's built-in SQLite.

Open goribu.config.js and declare the database. Uncomment the D1 line (or paste the code below) and give it a name:

// goribu.config.js
export default {
  database: { type: "d1", name: "my-site-db" },
};

That's the only place the database is declared. Goribu synthesizes the Cloudflare binding from it and creates the database for you on first deploy. No need to touch wrangler.jsonc for this.

Now scaffold a migration to create the subscribers table and teach D1 your schema:

npx goribu migration:create create_subscribers_table

That drops a timestamped file into migrations/. Open it and replace its contents:

export async function up(db) {
  await db.run(`
    CREATE TABLE subscribers (
      id         INTEGER PRIMARY KEY,
      email      TEXT NOT NULL UNIQUE,
      name       TEXT,
      created_at TEXT NOT NULL DEFAULT (datetime('now'))
    )
  `);
}

export async function down(db) {
  await db.run(`DROP TABLE subscribers`);
}

On deploy, pending migrations run automatically before the new Worker ships. Locally you apply them yourself so run:

npx goribu migrate

Your local D1 now has the subscribers table.

4. Collect signups

Now make the page do something. Add a <Form> to your Home — it renders a real form that submits without a full reload, and a little state holds any error it sends back. Here's the page again with the form added:

// src/routes/index.jsx
import { Form } from "goribu";
import { useState } from "react";

export const meta = {
  title: "Aggelos",
  description: "Aggelos's personal website.",
};

const NAME = "Aggelos";

export default function Home() {
  const [error, setError] = useState(null);
  return (
    <main className="site-page">
      <h1>Hello, I'm {NAME} and this is my website.</h1>
      <p>
        Here you'll learn a little about who I am, what I'm building, and the
        things I care about. Stick around and sign up to the newsletter if you'd
        like to follow along.
      </p>
      <Form
        method="post"
        className="subscribe-form"
        onSubmit={() => setError(null)}
        onError={(data) => setError(data?.error ?? "Something went wrong.")}
      >
        <input
          type="email"
          name="email"
          required
          placeholder="you@example.com"
          aria-label="Email"
        />
        <input
          type="text"
          name="name"
          placeholder="Your name (optional)"
          aria-label="Name"
        />
        <button type="submit">
          Subscribe
        </button>
      </Form>
      {error && <p className="form-error">{error}</p>}
    </main>
  );
}

export function GET(req, res) {
  return res.render(Home, {});
}

<Form> and its handler live in the same file, so the page and the action that powers it stay together. The GET from step 2 is unchanged. Now add the POST that handles the submission — the form posts to its own route by default, so it lands here:

// src/routes/index.jsx (add to the same file)
export async function POST(req, res) {
  const { email = "", name = "" } = await req.body();
  const cleanEmail = email.trim().toLowerCase();

  if (!cleanEmail.includes("@"))
    return res.status(422).json({ error: "Please enter a valid email." });

  try {
    await req.d1.run("INSERT INTO subscribers (email, name) VALUES (?, ?)", [
      cleanEmail,
      name.trim() || "",
    ]);
  } catch {
    return res.status(409).json({ error: "You're already subscribed!" });
  }

  // Success → redirect to the thank-you page. Carry the name via the query
  // string. With JS this is followed and SPA-swapped; without JS the native
  // POST follows the 302 the old-fashioned way.
  const greetName = name.trim();
  return res.redirect(
    greetName ? `/greet?name=${encodeURIComponent(greetName)}` : "/greet",
  );
}

This one handler shows off both of Goribu's responses, and <Form> just follows whichever it gets back:

  • await req.body() reads the submitted fields. We lowercase the email on the way in so Foo@x.com and foo@x.com aren't two people, and pass values to SQL as ? placeholders — always parameterize, never string-interpolate user input.
  • Errors come back as JSON and the page reacts in place. A res.json with a 4xx status doesn't navigate. Instead <Form>'s onError fires with the parsed body, so we drop the message into state and show it under the form. No reload, the typed-in values stay put.
  • Success redirects. On a clean insert we res.redirect to a thank-you page and <Form> performs the navigation. (The UNIQUE constraint on email makes a duplicate signup throw, which we catch as that friendly 409.)

Taking this to production? A quick @ check only proves an address looks roughly email-shaped, not that it can receive mail. The real guarantee is double opt-in (a confirmation link), which providers like Loops and Resend handle for you.

Last piece: create the redirect target at src/routes/greet.jsx (→ /greet):

// src/routes/greet.jsx
import { Link } from "goribu";

export function meta({ props }) {
  return {
    title: props.name
      ? `Hi ${props.name} · Aggelos`
      : "You're subscribed · Aggelos",
    description: "Thanks for subscribing.",
  };
}

export default function Greet({ name }) {
  return (
    <main className="site-page">
      <h1>Hi {name ? <span>{name}</span> : ""} 👋</h1>
      <p>
        Thanks for subscribing! You're on the list. I'll send the occasional
        update and nothing more. Talk soon.
      </p>

      <Link href="/" className="home-link">
        ← Back home
      </Link>
    </main>
  );
}

export function GET(req, res) {
  const { name = "" } = req.query();
  return res.render(Greet, { name });
}

Subscribe on the home page and you land on /greet. The row is now stored in your local D1 and every new submission appends one.

5. Go live

Deploying needs a Cloudflare account (the free Workers plan is enough). The first time you run it, a browser opens to log in to Wrangler, Cloudflare's CLI that Goribu drives under the hood.

npm run deploy

Goribu creates the D1 database if it doesn't exist yet, runs your pending migrations against it and then ships the Worker. A few seconds later you'll see your live URL.

That's a real personal site with working signups, server-rendered on Cloudflare's edge. From here you might pipe new subscribers into Loops, Resend or any email provider, but the foundation is done.