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.