Build a notebook
Let's build a small app that saves notes. It uses HTML forms and SQLite, so it works without browser JavaScript.
You'll keep routes, templates, and data access in separate files. The finished source is in docs/samples/tutorial/.
1. Set up
From a checkout of Brevity, using Node 22 or 24:
npm ci
npm run build:packagesThe dependencies are already installed: Brevity's optional web and views packages, Drizzle, and the libSQL SQLite client. This is a local learning app without accounts; don't expose it as a public notes service.
TypeScript settings for a separate app
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"types": ["node"]
},
"include": ["**/*.ts"],
"exclude": ["island/client", "island/dist"]
}Adjust include and exclude to your files. For local package installation, see Quick Start.
2. Start with a route
import { createApp } from "@portablesheep/brevity";
export const app = createApp();
app.router.get("/", (_req, res) => res.json({ message: "Hello, Brevity" }));The handler sends a JSON response. A separate entry point starts the server:
import { app } from "./first-route.js";
app.listen(3000);node --import tsx docs/samples/first-route-start.tsVisit http://127.0.0.1:3000. Stop this process before starting the notebook.
3. Build the native form
First, configure the HTML renderer. We'll use Eta file views with a shared layout:
import { fileURLToPath } from "node:url";
import { createFormState } from "@portablesheep/brevity-web";
import { createViews } from "@portablesheep/brevity-views";
export const views = createViews({
root: fileURLToPath(new URL("./views", import.meta.url)),
layout: "layout",
});
export const emptyForm = () => createFormState({ labels: { text: "Note" } });The layout wraps every page:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><title>Notebook</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body><main><h1>Notebook</h1><%= it.body %></main></body>
</html>The notes page includes the form and lists saved notes:
<%= await it.partial("form") %>
<ul><% for (const note of it.notes) { %><li><%= note.text %></li><% } %></ul>
<%= it.enhancement %>The form uses createFormState to connect labels and errors to each field, and to keep the user's input after a validation error:
<% const field = it.form.field("text", { describedBy: "text-hint" }); %>
<%= it.form.errorSummary() %>
<form method="post" action="/notes">
<label for="<%= field.id %>">Note</label>
<p id="text-hint">Write between 1 and 200 characters.</p>
<textarea id="<%= field.id %>" name="<%= field.name %>"
aria-invalid="<%= field.invalid %>" aria-describedby="<%= field.describedBy %>"
maxlength="200"><%= field.value %></textarea>
<%= it.form.fieldErrors("text") %>
<button type="submit">Save note</button>
</form><%= ... %> escapes text. Partials and form-feedback fragments keep their HTML. Don't mark user-entered note text as trusted markup. The enhancement slot stays empty for now; later, you can add a Svelte counter.
4. Add persistence
Use Drizzle to define a table and open a local SQLite file:
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const notes = sqliteTable("notes", {
id: integer("id").primaryKey({ autoIncrement: true }),
text: text("text").notNull(),
});
export async function openStore(url: string) {
const client = createClient({ url });
const db = drizzle(client);
try {
// A fixed schema for this tutorial; use versioned migrations in a real app.
await client.execute(
"CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL)",
);
} catch (error) {
client.close();
throw error;
}
return {
async list() {
return db.select().from(notes).orderBy(notes.id);
},
async add(text: string) {
return db.transaction(async (tx) => {
const [note] = await tx.insert(notes).values({ text }).returning();
if (!note) throw new Error("Insert returned no note");
return note;
});
},
close() {
client.close();
},
};
}
export type NoteStore = Awaited<ReturnType<typeof openStore>>;The repository exposes list(), add(), and close(). Routes don't need to know which database implements them. add() runs in a transaction; a thrown error rolls it back.
This first schema uses CREATE TABLE IF NOT EXISTS. It won't update an existing table: use versioned migrations when your schema changes.
5. Wire GET, POST, errors, and redirect
import { createApp, HttpError } from "@portablesheep/brevity";
import { createFormState, type FormState, type HtmlFragment } from "@portablesheep/brevity-web";
import { views, emptyForm } from "./page.js";
import type { NoteStore } from "./store.js";
export function createNotebook(store: NoteStore, enhance?: (form: FormState) => HtmlFragment) {
const app = createApp({ maxBodyBytes: 4096 });
app.use(async (req, res, next) => {
res.header("referrer-policy", "same-origin");
// Native same-origin forms send Origin. Reject cross-site writes before mutation.
if (req.method === "POST" && req.headers.origin !== `http://${req.headers.host}`) {
throw new HttpError(403, "Submit the form from this site");
}
return next(req, res);
});
app.router.get("/", async (_req, res) => {
const form = emptyForm();
await views.render(res, "notes", {
notes: await store.list(),
form,
enhancement: enhance?.(form),
});
});
app.router.head("/", (_req, res) => res.send(""));
app.router.post("/notes", async (req, res) => {
if (!req.form || req.form.getAll("text").length !== 1) {
throw new HttpError(400, "Send exactly one note using the HTML form");
}
const text = req.form.get("text") ?? "";
if (!text.trim() || text.length > 200) {
const form = createFormState({
values: { text },
labels: { text: "Note" },
errors: { text: ["Write between 1 and 200 characters."] },
});
await views.render(
res,
"notes",
{
notes: await store.list(),
form,
enhancement: enhance?.(form),
},
{ status: 422 },
);
return;
}
await store.add(text.trim());
res.redirect("/", 303);
});
return app;
}The GET shows notes. The POST checks the input, saves a valid note, and redirects with 303 so refreshing the page doesn't submit it again. Invalid input returns 422 with the original value and an error message.
The app limits request bodies to 4 KiB. It also rejects duplicate fields and cross-origin submissions.
Before deploying this example
Its Origin check is for local HTTP. A public app needs a configured HTTPS origin, trusted proxy settings, and access control. The Supabase recipe shows a fixed-origin setup.
6. Own the server lifecycle
Open the database, start the app, and close the database when the server stops:
import { createNotebook } from "./app.js";
import { openStore } from "./store.js";
const store = await openStore(process.env.DATABASE_URL ?? "file:notebook.db");
const app = createNotebook(store);
const server = app.listen(Number(process.env.PORT ?? 3000));
server.on("listening", () => {
const address = server.address();
if (!address || typeof address === "string") throw new Error("Expected TCP listener");
console.log(`Notebook: http://127.0.0.1:${address.port}`);
});
server.on("error", (error) => {
console.error(error);
store.close();
process.exitCode = 1;
});
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.once(signal, () => {
server.close((error) => {
store.close();
if (error) {
console.error(error);
process.exitCode = 1;
}
});
server.closeIdleConnections();
});
}npm run docs:appOpen http://127.0.0.1:3000. Try an empty note to see the error feedback. Save a note, restart the app, and check that it's still there in notebook.db. For a deployed service, add a shutdown deadline.
7. Test through HTTP, not a private adapter
Use listen(0) to let the OS choose a free port for each test:
import assert from "node:assert/strict";
import { once } from "node:events";
import type { TestContext } from "node:test";
import type { App } from "@portablesheep/brevity";
export async function serve(t: TestContext, app: App): Promise<string> {
const server = app.listen(0, "127.0.0.1");
t.after(async () => {
server.closeAllConnections();
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
});
await once(server, "listening");
const address = server.address();
assert(address && typeof address !== "string");
return `http://127.0.0.1:${address.port}`;
}The notebook tests check validation, escaping, redirects, and persistence after reopening the database. Run them with:
npm run docs:checkFor browser tests with JavaScript on and off, use the Playwright recipe.
Make it yours
Try adding a timestamp to each note. You'll change the schema, add a migration, render the timestamp, and update a test. Then choose Prisma for a different database layer, Supabase for sign-in, or Svelte for a small browser enhancement.