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.