---
title: Add a feature
description: 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](/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`:

```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:

```ts
export { note } from "./notes";
```

## Step 2. Create the migration

A migration is a file that changes the shape of your database.

```bash
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:

```bash
pnpm run db:migrate
```

Your database now has a `note` table.

:::note
Always run these two commands in this order. `db:generate` writes the file,
`db:migrate` applies it. The file is committed to git, so your teammates get
the same change.
:::

## 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](/apps/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:

```ts
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.

```tsx
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:

```ts
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](/guides/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.

## Related

- [Database](/packages/database) — tables, migrations and queries
- [API](/apps/api) — when to use `apps/api` instead
- [Testing](/guides/testing) — writing the test
- [Authorization](/packages/authorization) — checks beyond "is this my row"
