Add a Feature

Add a Feature

Every feature follows the same structure. Here's how to scaffold one from scratch, following src/features/notes/ as the reference implementation, see The Postgres port for the DB-layer architecture this pattern relies on.

Feature structure

src/features/my-feature/
├── index.ts                          # barrel: public API
├── my-feature.constants.ts           # types + zod schemas, client-safe
└── server/
    ├── my-feature.server.ts          # DB layer: row/view mapping, CRUD fns
    ├── my-feature.queries.ts         # read-only server functions (GET)
    ├── my-feature.mutations.ts       # write server functions (POST)
    └── my-feature-crud.test.ts       # integration tests

Step 1: Schema

Add your table to src/lib/db/schema.ts:

export const myItems = pgTable(
  'my_items',
  {
    id: text('id').primaryKey(),
    userId: text('user_id')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade' }),
    title: text('title').notNull(),
    createdAt: timestamp('created_at').notNull()
  },
  table => [index('my_items_user_id_idx').on(table.userId)]
);

Then:

bun run db:generate   # drizzle-kit generate, writes drizzle/*.sql
bun run db:migrate    # applies it against DATABASE_URL

Step 2: DB layer

Create src/features/my-feature/server/my-feature.server.ts:

import { ok } from '@bitclaw/result';
import { desc, eq } from 'drizzle-orm';
import { uuidv7 } from 'uuidv7';
import { myItems } from '@/lib/db/schema';
import type { UserDb } from '@/lib/db/user-db';

export const listItems = async (db: UserDb, userId: string) =>
  db.select().from(myItems).where(eq(myItems.userId, userId)).orderBy(desc(myItems.createdAt));

export const createItem = async (
  db: UserDb,
  userId: string,
  input: { title: string }
) => {
  const id = uuidv7();
  await db.insert(myItems).values({ id, userId, title: input.title, createdAt: new Date() });
  return ok({ id, title: input.title });
};

UserDb accepts either the ambient shared db (read-only queries) or a withUserTransaction tx handle (writes), both expose the same query-builder surface.

Step 3: Server functions

Create src/features/my-feature/server/my-feature.queries.ts:

import { err } from '@bitclaw/result';
import { createServerFn } from '@tanstack/react-start';
import { ERROR_CODES } from '@/lib/constants';
import { db } from '@/lib/db';
import { requireUser } from '@/server/require-user';
import { listItems } from './my-feature.server';

export const getItems = createServerFn({ method: 'GET' }).handler(async () => {
  const user = await requireUser();
  if (!user) return err(ERROR_CODES.UNAUTHORIZED, 'Not authenticated');

  return ok(await listItems(db, user.id));
});

Create src/features/my-feature/server/my-feature.mutations.ts:

import { err } from '@bitclaw/result';
import { createServerFn } from '@tanstack/react-start';
import { z } from 'zod';
import { ERROR_CODES } from '@/lib/constants';
import { withUserTransaction } from '@/lib/db/user-db';
import { logUserEvent } from '@/lib/db/user-events';
import { requireUser } from '@/server/require-user';
import { createItem } from './my-feature.server';

export const createItemFn = createServerFn({ method: 'POST' })
  .inputValidator(z.object({ title: z.string().min(1).max(200) }))
  .handler(async ({ data }) => {
    const user = await requireUser();
    if (!user) return err(ERROR_CODES.UNAUTHORIZED, 'Not authenticated');

    return withUserTransaction(user.id, async (tx, userId) => {
      const result = await createItem(tx, userId, data);
      if (!result.ok) return result;
      await logUserEvent(tx, userId, 'item.created', { id: result.data.id });
      return result;
    });
  });

Always requireUser() first. All writes go inside withUserTransaction, using the tx handle passed into the closure for every query, not the outer db. logUserEvent writes to the shared userEvents table (still user-scoped by user_id), called with the same tx handle so it's part of the same transaction as the write it's logging.

Step 4: Tests

Create src/features/my-feature/server/my-feature-crud.test.ts:

import { describe, expect, it } from 'bun:test';
import { users } from '@/lib/db/schema';
import { withTestDb } from '@/test/db';
import { makeUser } from '@/test/fixtures';
import { createItem, listItems } from './my-feature.server';

describe('my-feature', () => {
  it('creates and lists items', () =>
    withTestDb(async tx => {
      const [user] = await tx.insert(users).values(makeUser()).returning();
      await createItem(tx, user?.id as string, { title: 'Test' });
      const items = await listItems(tx, user?.id as string);
      expect(items[0]?.title).toBe('Test');
    }));
});

withTestDb runs the test inside a real transaction against the dockerized test Postgres and always rolls it back, no mocks. Pass the tx handle to every DB call in the test, not the ambient db export, or writes escape the rollback.

Run: bun test src/features/my-feature

Step 5: Barrel

Create src/features/my-feature/index.ts:

export { getItems } from './server/my-feature.queries';
export { createItemFn } from './server/my-feature.mutations';

Step 6: Route

Create src/routes/_app.dashboard.my-feature.tsx:

import { createFileRoute } from '@tanstack/react-router';
import { getItems } from '@/features/my-feature';

export const Route = createFileRoute('/_app/dashboard/my-feature')({
  component: MyFeaturePage,
  loader: () => getItems()
});

function MyFeaturePage() {
  const result = Route.useLoaderData();
  return (
    <ul>
      {result.data?.map(item => <li key={item.id}>{item.title}</li>)}
    </ul>
  );
}

Step 7: Generate route tree

bun run generate
bun run dev

Visit /dashboard/my-feature.