Add a feature
Build one feature from end to end — a database table, a server function and a page — so you can see how the parts of zap.ts fit together.
This page builds one small feature all the way through. You will add a table to the database, read it on the server, and show it on a page.
The feature: a note. Each user can save short notes and see them listed.
It is deliberately simple. The point is the path, not the feature.
Before you start
You need the app running. See Quickstart.
Step 1. Add the table
Tables live in packages/database/src.
The file schema.ts holds tables that come from sign-in. Those are generated,
so do not edit them. Put your own tables in a new file next to it.
Create packages/database/src/notes.ts:
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { user } from "./auth.schema";
export const note = pgTable("note", {
id: uuid("id").defaultRandom().primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
body: text("body").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
Two things are worth noticing.
references(() => user.id) links a note to the user who wrote it.
onDelete: "cascade" means that when a user is deleted, their notes go too.
Now export it from packages/database/src/schema.ts, so the rest of the app
can reach it:
export { note } from "./notes";
Step 2. Create the migration
A migration is a file that changes the shape of your database.
pnpm run db:generate
This looks at your tables, compares them to the database, and writes a migration file describing the difference. Open it and read it. It is plain SQL.
Apply it:
pnpm run db:migrate
Your database now has a note table.
Step 3. Read the notes on the server
Now the app needs to fetch notes. This is server work, so it belongs in
apps/web, not in apps/api. See API for why.
TanStack Start calls this a server function. It is a function that always runs on the server, even though you write it in a file your page imports.
In your route file:
import { createServerFn } from "@tanstack/react-start";
import { getRequestHeaders } from "@tanstack/react-start/server";
import { createAuth } from "@zap-ts/authentication/auth";
import { createAuthDatabase } from "@zap-ts/database/auth.database";
import { note } from "@zap-ts/database/schema";
import { desc, eq } from "drizzle-orm";
import { databaseUrl } from "../lib/database-url";
const getNotes = createServerFn().handler(async () => {
const headers = new Headers(getRequestHeaders());
const auth = createAuth(databaseUrl());
const session = await auth.api.getSession({ headers });
if (!session) {
return [];
}
return createAuthDatabase(databaseUrl())
.select()
.from(note)
.where(eq(note.userId, session.user.id))
.orderBy(desc(note.createdAt));
});
The important line is where(eq(note.userId, session.user.id)).
Always filter by the signed-in user on the server. Never trust an ID that came from the browser. A user could change it and read someone else’s notes.
Step 4. Show them on a page
Routes live in apps/web/src/routes. The file name becomes the address.
Create apps/web/src/routes/_protected.dashboard.notes.tsx. The
_protected part means the page requires a signed-in user. zap.ts handles
that for you.
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/_protected/dashboard/notes")({
component: NotesPage,
loader: () => getNotes(),
});
function NotesPage() {
const notes = Route.useLoaderData();
return (
<ul>
{notes.map((item) => (
<li key={item.id}>{item.body}</li>
))}
</ul>
);
}
The loader runs before the page is shown. So the notes arrive with the page,
not after it. The user never sees an empty box that fills in later.
Open /dashboard/notes in the app. Your feature is live.
Step 5. Write a test
Give it a real database, not a fake one:
import { createTestDatabase } from "@zap-ts/testing/database";
import { afterAll, expect, test } from "vitest";
const database = await createTestDatabase();
afterAll(() => database.close());
test("a note belongs to its user", async () => {
// insert a user and a note, then read the notes back
// and check that another user's notes are not returned
});
Name the file notes.node.test.ts and put it next to the code it tests. See
Testing.
What you just learned
This is the shape of every feature in zap.ts.
- The table goes in
packages/database. - A migration records the change.
- The server function goes in
apps/web, beside the page that uses it. - The page reads it through a loader.
- The test uses a real database.
Your own features are bigger. The path is the same.
Related
- Database — tables, migrations and queries
- API — when to use
apps/apiinstead - Testing — writing the test
- Authorization — checks beyond “is this my row”