Skip to content
zap.ts
Esc
navigateopen⌘Jpreview
On this page

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.

  1. The table goes in packages/database.
  2. A migration records the change.
  3. The server function goes in apps/web, beside the page that uses it.
  4. The page reads it through a loader.
  5. The test uses a real database.

Your own features are bigger. The path is the same.

  • Database — tables, migrations and queries
  • API — when to use apps/api instead
  • Testing — writing the test
  • Authorization — checks beyond “is this my row”

Last updated on September 22, 2026

Was this page helpful?