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:
pnpm create goribu@latest feedback-app --js
cd feedback-app
pnpm installThe 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:
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
pnpm exec goribu migrate:new create feedbackReplace the generated migration's SQL with:
-- 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:
pnpm exec goribu migrateThe 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:
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
pnpm devOpen /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
pnpm build
pnpm --dir node_modules/goribu exec wrangler login
pnpm check
pnpm run deployReview 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.