Tutorial

Build a small feedback app with a D1 table, a form, validation and a list of saved messages.

On this page

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:Use the JavaScript starter so the examples below can be pasted directly:

Terminal
pnpm create goribu@latest guestbook
cd guestbook && pnpm install && pnpm dev
Terminal
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:

FileWhat it's for
src/routes/index.tsxsrc/routes/index.jsxThe page at /. Every file in src/routes/ becomes a route.
src/styles/global.cssApp-wide CSS, already imported by src/setup/client-entry.js.
goribu.config.jsApp 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.tsxsrc/routes/index.jsx and swap its contents for a first version of the guestbook, with a couple of hardcoded messages for now:

src/routes/index.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 (
    <main className="guestbook">
      <header>
        <h1>Guestbook</h1>
        <p>Say hello and leave a note for the next visitor.</p>
      </header>

      <h2>Recent messages</h2>
      <ul className="messages">
        {messages.map((item) => (
          <li key={item.id}>
            <strong>{item.name}</strong>
            <p>{item.message}</p>
          </li>
        ))}
      </ul>
    </main>
  );
}
src/routes/index.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 (
    <main className="guestbook">
      <header>
        <h1>Guestbook</h1>
        <p>Say hello and leave a note for the next visitor.</p>
      </header>

      <h2>Recent messages</h2>
      <ul className="messages">
        {messages.map((item) => (
          <li key={item.id}>
            <strong>{item.name}</strong>
            <p>{item.message}</p>
          </li>
        ))}
      </ul>
    </main>
  );
}

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
@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
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
pnpm exec goribu migrate:new create messages

Replace the generated file's SQL with:

migrations/0001_create-messages.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
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
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
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<Props> = async (req, res) => {
  const messages = await req.d1<Message>`
    SELECT id, name, message FROM messages ORDER BY id DESC LIMIT 50
  `.all();

  return res.render({ messages });
};

export default function GuestbookPage({ messages }: Props) {
  return (
    <main className="guestbook">
      <header>
        <h1>Guestbook</h1>
        <p>Say hello and leave a note for the next visitor.</p>
      </header>

      <h2>Recent messages</h2>
      {messages.length ? (
        <ul className="messages">
          {messages.map((item) => (
            <li key={item.id}>
              <strong>{item.name}</strong>
              <p>{item.message}</p>
            </li>
          ))}
        </ul>
      ) : (
        <p className="empty">No messages yet. Be the first!</p>
      )}
    </main>
  );
}
src/routes/index.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 (
    <main className="guestbook">
      <header>
        <h1>Guestbook</h1>
        <p>Say hello and leave a note for the next visitor.</p>
      </header>

      <h2>Recent messages</h2>
      {messages.length ? (
        <ul className="messages">
          {messages.map((item) => (
            <li key={item.id}>
              <strong>{item.name}</strong>
              <p>{item.message}</p>
            </li>
          ))}
        </ul>
      ) : (
        <p className="empty">No messages yet. Be the first!</p>
      )}
    </main>
  );
}

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
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<Props> = async (req, res) => {
  const fields = await req.body() as Record<string, unknown>;

  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
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
// The React form
<Form method="post">
  {({ pending }) => (
    <>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" maxLength={80} />

      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" maxLength={2000} />

      <button disabled={pending}>
        {pending ? "Sending…" : "Sign the guestbook"}
      </button>
    </>
  )}
</Form>
src/routes/index.jsx
// The React form
<Form method="post">
  {({ pending }) => (
    <>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" maxLength={80} />

      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" maxLength={2000} />

      <button disabled={pending}>
        {pending ? "Sending…" : "Sign the guestbook"}
      </button>
    </>
  )}
</Form>

Go ahead and try it. Here's what just happened:

  • The form posted to the same route, so its POST handler ran. req.body() gave you the fields as a plain object.
  • The redirect sent the browser back to /, which ran GET again with the fresh list.
  • Your own write showed up immediately. D1's session bookmark carries it into that follow-up request, so you never see a stale list right after saving.
  • pending was true while the request was in progress, which is why the button changed its label.

Validate the input

Right now someone can submit an empty form or skip the browser entirely and send whatever they like. The maxLength attributes are a nice hint, but the protection has to come from the server making that checks. Replace POST handler with a version that validates first:

src/routes/index.tsx
// Read and validate the submitted form, and save before redirecting.
export const POST: Handler<Props> = async (req, res) => {
  const fields = await req.body() as Record<string, unknown>;

  const name = typeof fields.name === "string" ? fields.name.trim() : "";
  const message = typeof fields.message === "string" ? fields.message.trim() : "";

  const errors: Record<string, string> = {};

  if (!name || name.length > 80) {
    errors.name = "Enter a name up to 80 characters.";
  }

  if (!message || message.length > 2000) {
    errors.message = "Enter a message up to 2,000 characters.";
  }

  if (Object.keys(errors).length) {
    return res.invalid(errors);
  }

  await req.d1`
    INSERT INTO messages (name, message, created_at)
    VALUES (${name}, ${message}, ${new Date().toISOString()})
  `.run();

  return res.redirect("/");
};
src/routes/index.jsx
// Read and validate the submitted form, and save before redirecting.
export const POST = async (req, res) => {
  const fields = await req.body();

  const name = typeof fields.name === "string" ? fields.name.trim() : "";
  const message = typeof fields.message === "string" ? fields.message.trim() : "";

  const errors = {};

  if (!name || name.length > 80) {
    errors.name = "Enter a name up to 80 characters.";
  }

  if (!message || message.length > 2000) {
    errors.message = "Enter a message up to 2,000 characters.";
  }

  if (Object.keys(errors).length) {
    return res.invalid(errors);
  }

  await req.d1`
    INSERT INTO messages (name, message, created_at)
    VALUES (${name}, ${message}, ${new Date().toISOString()})
  `.run();

  return res.redirect("/");
};

res.invalid(errors) keeps the visitor on the form and hands those messages back to it. Let's show them. Update the form to read errors too:

src/routes/index.tsx
<Form method="post">
  {({ pending, errors }) => (
    <>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" maxLength={80} />
      <p className="error">{errors?.name}</p>

      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" maxLength={2000} />
      <p className="error">{errors?.message}</p>

      {errors?.root && <p className="error" role="alert">{errors.root}</p>}

      <button disabled={pending}>
        {pending ? "Sending…" : "Sign the guestbook"}
      </button>
    </>
  )}
</Form>
src/routes/index.jsx
<Form method="post">
  {({ pending, errors }) => (
    <>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" maxLength={80} />
      <p className="error">{errors?.name}</p>

      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" maxLength={2000} />
      <p className="error">{errors?.message}</p>

      {errors?.root && <p className="error" role="alert">{errors.root}</p>}

      <button disabled={pending}>
        {pending ? "Sending…" : "Sign the guestbook"}
      </button>
    </>
  )}
</Form>

Submit an empty form and each field tells you what's wrong. The errors.root line is for form-wide problems: calling res.invalid("Something went wrong.") with a string instead of an object puts that message there. It's good practice to always render it.

One thing you didn't have to do is escape the message. React renders it as text, so any HTML someone types shows up as harmless characters.

Tidy up the server code

The route works, but the SQL sits inside the page file. Goribu has a convention for code that must never reach the browser: put it in a .server file outside src/routes/. Create:

src/messages.server.ts
import type { Handler } from "goribu/server";

// The request object every handler receives.
type Request = Parameters<Handler>[0];

export type Message = { id: number; name: string; message: string };

export function listMessages(req: Request) {
  return req.d1<Message>`
    SELECT id, name, message FROM messages ORDER BY id DESC LIMIT 50
  `.all();
}

export function saveMessage(req: Request, name: string, message: string) {
  return req.d1`
    INSERT INTO messages (name, message, created_at)
    VALUES (${name}, ${message}, ${new Date().toISOString()})
  `.run();
}
src/messages.server.js
export function listMessages(req) {
  return req.d1`
    SELECT id, name, message FROM messages ORDER BY id DESC LIMIT 50
  `.all();
}

export function saveMessage(req, name, message) {
  return req.d1`
    INSERT INTO messages (name, message, created_at)
    VALUES (${name}, ${message}, ${new Date().toISOString()})
  `.run();
}

Back in the route, replace the local Message type with the imported one and update both handlers as shown below. Keep the page component, its Form import, and metadata as they are:

src/routes/index.tsx
import type { Handler } from "goribu/server";
import { listMessages, saveMessage, type Message } from "../messages.server";

type Props = { messages: Message[] };

export const GET: Handler<Props> = async (req, res) => {
  return res.render({ messages: await listMessages(req) });
};

export const POST: Handler<Props> = async (req, res) => {
  const fields = await req.body() as Record<string, unknown>;

  const name = typeof fields.name === "string" ? fields.name.trim() : "";
  const message = typeof fields.message === "string" ? fields.message.trim() : "";

  const errors: Record<string, string> = {};

  if (!name || name.length > 80) {
    errors.name = "Enter a name up to 80 characters.";
  }

  if (!message || message.length > 2000) {
    errors.message = "Enter a message up to 2,000 characters.";
  }

  if (Object.keys(errors).length) {
    return res.invalid(errors);
  }

  await saveMessage(req, name, message);
  return res.redirect("/");
};
src/routes/index.jsx
import { listMessages, saveMessage } from "../messages.server";

export const GET = async (req, res) => {
  return res.render({ messages: await listMessages(req) });
};

export const POST = async (req, res) => {
  const fields = await req.body();

  const name = typeof fields.name === "string" ? fields.name.trim() : "";
  const message = typeof fields.message === "string" ? fields.message.trim() : "";

  const errors = {};

  if (!name || name.length > 80) {
    errors.name = "Enter a name up to 80 characters.";
  }

  if (!message || message.length > 2000) {
    errors.message = "Enter a message up to 2,000 characters.";
  }

  if (Object.keys(errors).length) {
    return res.invalid(errors);
  }

  await saveMessage(req, name, message);
  return res.redirect("/");
};

Same behavior, but now the route reads like a description of the page and the database details are in one dedicated place of their own.

Try it all locally

With pnpm dev still running, give it a final check:

  • Validation: submit an empty form and check that both fields explain the problem.
  • Pending state: send a real message and watch the button change while it saves.
  • Saving: your message appears at the top of the list.
  • Persistence: reload the page or peek with pnpm exec goribu d1 "SELECT * FROM messages".

Deploy

Happy with it? Let's ship it so you can see it in action:

Terminal
pnpm typecheck
pnpm build
pnpm exec goribu cloudflare-login
pnpm check
pnpm run deploy
Terminal
pnpm build
pnpm exec goribu cloudflare-login
pnpm check
pnpm run deploy
  • Read the plan first: pnpm check is an optional step but it shows what will be created or changed without touching anything. Look it over before deploying.
  • Database setup: the deploy creates the production database and applies your migration before uploading the Worker.
  • Fresh data: your local test messages stay local. Production starts with an empty guestbook and a new database.

Open the live URL that is shown in your logs. Feel free to sign the guestbook one more time. That one's for real. 🎉

Keep in mind that this guestbook is public: anyone can post. Before sharing it widely, consider adding authentication or rate limiting.

Where to go next

You've used every core part of Goribu. Each one has its own guide:

  • Routing for more pages and dynamic segments like [id].tsx.
  • Rendering for prerendering, the Document, and metadata.
  • Forms for richer interactions and fetch mutations.
  • D1 for queries, consistency, and schema changes.
  • Deployment for environments and production checks.

Some ideas to extend the guestbook:

  • Delete button: a <Form method="delete"> with a DELETE handler.
  • Pagination: read req.query.page in GET and adjust the LIMIT/OFFSET.
  • Message page: a src/routes/messages/[id].tsx route that shows a single message with its date.