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.