Quick Start
Create your first feature and understand the development workflow
Your First Feature
Let's create a simple "tasks" feature to understand the development workflow.
Step 1: Create the Feature Directory
mkdir -p src/features/tasksStep 2: Define the Schema
Create src/features/tasks/schema.ts:
import { baseTableSchema } from "@/db/enums";
import { z } from "zod";
export const MIN_TASK_TITLE = 3;
export const MAX_TASK_TITLE = 100;
// Main schema
export const taskSchema = baseTableSchema.extend({
title: z.string().min(MIN_TASK_TITLE).max(MAX_TASK_TITLE),
description: z.string().optional(),
completed: z.boolean().default(false),
ownerId: z.string().uuid(),
});
// Upsert schema
export const taskUpsertSchema = taskSchema
.omit({ createdAt: true, updatedAt: true })
.partial({ id: true });
// Types
export type Task = z.infer<typeof taskSchema>;
export type TaskUpsert = z.infer<typeof taskUpsertSchema>;Step 3: Create the Database Table
Create src/db/tables/tasks.ts:
import { createTable } from "@/db/table-utils";
import { relations } from "drizzle-orm";
import { boolean, text, uuid } from "drizzle-orm/pg-core";
import { users } from "./auth";
export const tasks = createTable("tasks", {
title: text("title").notNull(),
description: text("description"),
completed: boolean("completed").notNull().default(false),
ownerId: uuid("owner_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
});
export const tasksRelations = relations(tasks, ({ one }) => ({
owner: one(users, {
fields: [tasks.ownerId],
references: [users.id],
}),
}));Export in src/db/tables/index.ts:
export * from "./tasks";Push to database:
npm run db:pushStep 4: Create Functions
Create src/features/tasks/functions.ts:
import { createDrizzleOperations } from "@/db/drizzle-operations";
import { CommonTableData } from "@/db/enums";
import { tasks } from "@/db/tables";
import { Task } from "./schema";
import { eq } from "drizzle-orm";
type DataCore = Omit<Task, keyof CommonTableData>;
const operations = createDrizzleOperations<typeof tasks, Task>({
table: tasks,
});
export async function list() {
return operations.listDocuments();
}
export async function get(id: string) {
return operations.getDocument(id);
}
export async function create(data: DataCore) {
return operations.createDocument(data);
}
export async function update(id: string, data: Partial<DataCore>) {
return operations.updateDocument(id, data);
}
export async function remove(id: string) {
return operations.removeDocument(id);
}
export async function getByOwnerId(ownerId: string) {
return operations.listDocuments(eq(tasks.ownerId, ownerId));
}Step 5: Add to Database Facade
Add to src/db/facade.ts:
import * as tasks from "@/features/tasks/functions";
export const dbFacade = {
// ... existing
tasks,
};Step 6: Create tRPC Router
Create src/trpc/procedures/routers/tasks.ts:
import { taskUpsertSchema } from "@/features/tasks/schema";
import { authProcedure, createTRPCRouter } from "@/trpc/procedures/trpc";
import { ActionResponse } from "@/types";
import { z } from "zod";
export const tasksRouter = createTRPCRouter({
list: authProcedure
.meta({ rateLimit: "QUERY" })
.input(z.object({}))
.query(async ({ ctx }) => {
const tasks = await ctx.db.tasks.getByOwnerId(ctx.user.id);
return {
success: true,
payload: tasks,
} satisfies ActionResponse;
}),
upsert: authProcedure
.meta({ rateLimit: "MUTATION" })
.input(taskUpsertSchema)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
const task = id
? await ctx.db.tasks.update(id, data)
: await ctx.db.tasks.create({ ...data, ownerId: ctx.user.id });
return {
success: true,
message: ctx.t("toasts.saved"),
payload: task,
} satisfies ActionResponse;
}),
delete: authProcedure
.meta({ rateLimit: "MUTATION" })
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
await ctx.db.tasks.remove(input.id);
return {
success: true,
message: ctx.t("toasts.deleted"),
} satisfies ActionResponse;
}),
});Add to src/trpc/procedures/root.ts:
import { tasksRouter } from "./routers/tasks";
export const appRouter = createTRPCRouter({
// ... existing
tasks: tasksRouter,
});Step 7: Create Client Hooks
Create src/features/tasks/hooks.ts:
"use client";
import { api } from "@/trpc/react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { taskUpsertSchema, TaskUpsert } from "./schema";
export function useTasksQuery() {
const query = api.tasks.list.useQuery({});
return {
tasks: query.data?.payload ?? [],
loading: query.isLoading,
refetch: query.refetch,
};
}
export function useTaskForm(defaultValues: TaskUpsert | null) {
const utils = api.useUtils();
const form = useForm<TaskUpsert>({
resolver: zodResolver(taskUpsertSchema),
defaultValues: defaultValues ?? { title: "", completed: false },
});
const mutation = api.tasks.upsert.useMutation({
onSuccess: () => {
utils.tasks.list.invalidate();
},
});
const save = form.handleSubmit(async (data) => {
await mutation.mutateAsync(data);
});
return {
form,
save,
loading: mutation.isPending,
};
}
export function useTaskDelete() {
const utils = api.useUtils();
const mutation = api.tasks.delete.useMutation({
onSuccess: () => {
utils.tasks.list.invalidate();
},
});
return {
deleteTask: (id: string) => mutation.mutateAsync({ id }),
loading: mutation.isPending,
};
}Step 8: Create a Simple Component
Create src/features/tasks/components/task-list.tsx:
"use client";
import { Button } from "@/components/ui/button";
import { Icon } from "@/lib/icons";
import { useTasksQuery, useTaskDelete } from "../hooks";
export function TaskList() {
const { tasks, loading } = useTasksQuery();
const { deleteTask } = useTaskDelete();
if (loading) return <div>Loading...</div>;
return (
<div className="space-y-2">
{tasks.map((task) => (
<div key={task.id} className="flex items-center gap-2 p-4 border rounded">
<span>{task.title}</span>
<Button
size="sm"
variant="destructive"
onClick={() => deleteTask(task.id)}
>
<Icon iconKey="trash" />
</Button>
</div>
))}
</div>
);
}Testing Your Feature
- Start the dev server:
npm run dev - Import and use
<TaskList />in any page - The tasks will be filtered by the current user automatically
What You Learned
- Schema definition with Zod
- Database table creation with Drizzle
- Server functions using
createDrizzleOperations - tRPC router with type-safe procedures
- Client hooks for queries and mutations
- Component integration with React Query
Next Steps
For more complex features with forms, dialogs, and field components:
- Feature Modules - Complete 5-file pattern
- Templates - Step-by-step feature creation guide
- Database Operations - Advanced queries and caching