Skip to content
Tutorial
Start hereTutorial
On this page

Tutorial

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

Create the app

Use the JavaScript starter so the examples below can be pasted directly:

Terminal
pnpm create goribu@latest feedback-app --js
cd feedback-app
pnpm install

The feedback page will be public: everyone can see and submit messages. This is a focused persistence example; add authentication and abuse controls before using it for private or unrestricted public submissions.

Declare the database

In goribu.config.js, add the database to the existing configuration. Keep the starter's compatibility date:

Configuration field
database: { type: "d1", name: "feedback-app-db" }

That line belongs inside defineConfig({ ... }). Goribu manages its local database and the eventual production binding.

Create the table

Terminal
pnpm exec goribu migrate:new create feedback

Replace the generated migration's SQL with:

migrations/0001_create-feedback.sql
-- migrate:up
CREATE TABLE feedback (
  id INTEGER PRIMARY KEY,
  name TEXT NOT NULL,
  message TEXT NOT NULL,
  created_at TEXT NOT NULL
);

-- migrate:down
DROP TABLE feedback;

Apply it locally before the page queries the table:

Terminal
pnpm exec goribu migrate

The down section drops the table and its data; it is shown to make the migration format explicit, not as a routine undo step.

Add the route

Create src/routes/feedback.jsx. The GET handler loads the list, the page displays it, and POST validates and saves a message:

src/routes/feedback.jsx
import { Form } from "goribu";

export const meta = { title: "Feedback" };

// Load the newest messages for the page.
export async function GET(req, res) {
  const messages = await req.d1`
    SELECT id, name, message FROM feedback ORDER BY id DESC LIMIT 50
  `.all();
  return res.render({ messages });
}

// Render the form and the most recent messages together.
export default function FeedbackPage({ messages }) {
  return (
    <main>
      <h1>Feedback</h1>
      <Form method="post">
        {({ pending, errors }) => (
          <>
            <label htmlFor="feedback-name">Name</label>
            <input id="feedback-name" name="name" maxLength={80}
              aria-describedby="name-error" />
            <p id="name-error">{errors?.name}</p>

            <label htmlFor="feedback-message">Message</label>
            <textarea id="feedback-message" name="message" maxLength={2000}
              aria-describedby="message-error" />
            <p id="message-error">{errors?.message}</p>
            <button disabled={pending}>
              {pending ? "Saving…" : "Send feedback"}
            </button>
          </>
        )}
      </Form>
      <h2>Recent messages</h2>
      {messages.length ? (
        <ul>
          {messages.map((item) => (
            <li key={item.id}>
              <strong>{item.name}</strong>
              <p>{item.message}</p>
            </li>
          ))}
        </ul>
      ) : <p>No feedback yet.</p>}
    </main>
  );
}

// Read either form encoding, validate, and save before redirecting.
export async function POST(req, res) {
  const body = await req.body();
  const fields = body instanceof FormData ? Object.fromEntries(body) : 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 feedback (name, message, created_at)
    VALUES (${name}, ${message}, ${new Date().toISOString()})
  `.run();
  return res.redirect("/feedback");
}

res.invalid() keeps an enhanced submission on the form and exposes named errors. The redirect after saving runs GET again, so the visitor sees the updated list. The D1 session bookmark carries the visitor's own write into that follow-up request.

No HTML string is assembled from the message: React renders it as text. The form's limits are also checked on the server.

Try it locally

Terminal
pnpm dev

Open /feedback. Submit an empty form, then a valid message. Check that validation points to the fields, the button reflects the pending request, and a successful save appears after navigation. Reload to confirm the record persists locally.

The starter's home page remains available. Add a Link to /feedback from it when you are ready.

Review and deploy

Terminal
pnpm build
pnpm --dir node_modules/goribu exec wrangler login
pnpm check
pnpm run deploy

Review the plan before the final command. Deployment creates the production database and applies the migration before uploading the Worker. Your local test messages are not uploaded.

Open the published /feedback URL and repeat the validation and save checks. From here, use Forms for richer interaction and D1 for queries, consistency, and schema changes.