Database
Store the site-notes VitNode plugin's notes in PostgreSQL with Drizzle ORM, generate and apply the migration, and replace the sample data with real queries.
VitNode stores data in PostgreSQL through Drizzle ORM. A plugin defines its tables in TypeScript, VitNode turns them into SQL migrations, and routes query them with c.get("db"). In this lesson the site-notes endpoints stop returning sample data and read a real site_notes table.
Before you begin
- The
site-notesplugin from Plugin page, withpnpm devrunning at the workspace root.
Define the table
Create src/database/notes.ts:
import { camelCase } from "drizzle-orm/pg-core";
export const site_notes = camelCase.table.withRLS("site_notes", t => ({
id: t.serial().primaryKey(),
title: t.varchar({ length: 200 }).notNull(),
body: t.text().notNull(),
createdAt: t.timestamp().notNull().defaultNow(),
}));- Every table is a named export of a file in
src/database/. The migration generator reads those exports from the plugin's build output; a table that is not exported there never reaches the database. - Prefix the table name with the plugin, as
site_notesdoes, so two plugins never claim the same table. camelCase.tablekeeps the TypeScript and column names identical (createdAt).withRLSturns on row-level security, as every core table does.
Generate and apply the migration
pnpm dev already rebuilt the plugin when you saved. From the workspace root, create the migration and apply it:
bun run db:migratedb:migrate compares your tables with the last migration, writes a new folder such as migrations/20261024183641_little_the_phantom/ in the app that owns the database (apps/web in a Single App, apps/api in a Monorepo App), applies it and seeds core data. Commit that folder, because production applies the same files. Without pnpm dev running, run pnpm build:plugins first so the generator sees the new table.
Add demo notes
There is no write endpoint yet, so add three notes with SQL. With the Docker database from Setup, run this from the workspace root (the my-vitnode folder):
docker compose -p my-vitnode-vitnode-dev exec -T database psql -U root -d vitnode -c "INSERT INTO site_notes (title, body) VALUES ('Welcome to site notes', 'Short updates for everyone on the site.'), ('Office closed on Friday', 'We are back on Monday at 9:00.'), ('New coffee machine', 'It is in the kitchen. Please descale it on Fridays.');"It answers INSERT 0 3. database is the PostgreSQL service in the generated docker-compose.yml, and -p my-vitnode-vitnode-dev is the Compose project that docker:dev started it in. If you named your project something else, use the -p value from the docker:dev script in your root package.json.
The notes get the ids 1, 2 and 3 in that order, which later lessons use. With your own PostgreSQL, run the same INSERT in any client connected to POSTGRES_URL. The Permissions lesson adds the endpoint that creates notes properly.
Read the table in the list route
Replace the handler of src/api/modules/notes/list.route.ts, and its imports. Select only the columns the response schema declares:
import { z } from "@hono/zod-openapi";
import { desc } from "drizzle-orm";
import { buildRoute } from "@/api/builders";
import { site_notes } from "@/database/notes";
import { zodNote } from "./notes.schema";
import { sampleNotes } from "./sample-notes";
export const listNotesRoute = buildRoute({
route: {
method: "get",
path: "/",
responses: {
200: {
content: { "application/json": { schema: z.array(zodNote) } },
description: "Every note, newest first.",
},
},
},
handler: c => c.json(sampleNotes, 200),
handler: async c => {
const notes = await c
.get("db")
.select({ id: site_notes.id, title: site_notes.title, body: site_notes.body })
.from(site_notes)
.orderBy(desc(site_notes.createdAt), desc(site_notes.id));
return c.json(notes, 200);
},
});Read the table in the detail route
Replace the handler of src/api/modules/notes/show.route.ts the same way. The params and responses stay as they are:
import { eq } from "drizzle-orm";
import { site_notes } from "@/database/notes";
import { sampleNotes } from "./sample-notes";
handler: async c => {
const { id } = c.req.valid("param");
const [note] = await c
.get("db")
.select({ id: site_notes.id, title: site_notes.title, body: site_notes.body })
.from(site_notes)
.where(eq(site_notes.id, id));
if (!note) return c.body(null, 404);
return c.json(note, 200);
},Then delete src/api/modules/notes/sample-notes.ts; nothing imports it any more.
Check the result
curl http://localhost:3000/api/site-notes/notesThe list now starts with New coffee machine, the newest note, and GET /api/site-notes/notes/2 returns Office closed on Friday. Reload http://localhost:3000/site-notes/notes and the page shows all three notes, with no change to its code. Run the INSERT again with another title and it appears on the next reload.
Go further
- Pagination and table search for long lists.
- Database commands:
vitnode db generate,migrate,statusandpush, when you need the steps separately. - Connection pool: sizing the pool for production, serverless and poolers.
- Content Engine: if your table is editorial content, one definition generates the table, the API and the AdminCP screens.