Recipes
Add New Feature
Complete guide to creating a new feature module
Add New Feature
TL;DR: Create 6 files, register router, add page. ~15 minutes.
What You'll Build
A complete CRUD feature with:
- Database table
- API endpoints (list, get, create, update, delete)
- List page with DataTable
- Create/Edit forms
File Reference
| # | File | Purpose |
|---|---|---|
| 1 | src/lib/db/schema/<name>.ts | Database table |
| 2 | src/features/<name>/validation.ts | Zod schemas |
| 3 | src/features/<name>/queries.ts | Read operations |
| 4 | src/features/<name>/actions.ts | Write operations |
| 5 | src/features/<name>/index.ts | Client exports |
| 6 | src/features/<name>/server.ts | Server exports |
| 7 | src/server/routers/<name>.ts | API router |
| 8 | src/server/routers/index.ts | Register router |
| 9 | src/app/(dashboard)/<name>/page.tsx | List page |
Step 1: Create Database Schema
// src/lib/db/schema/projects.ts
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"
import { organizations } from "./auth"
export const projects = pgTable("projects", {
id: uuid("id").primaryKey().defaultRandom(),
organizationId: uuid("organization_id")
.notNull()
.references(() => organizations.id, { onDelete: "cascade" }),
name: text("name").notNull(),
description: text("description"),
status: text("status").notNull().default("active"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
})Register in schema index:
// src/lib/db/schema/index.ts
export * from "./projects"Push to database:
npm run db:pushStep 2: Create Validation Schemas
// src/features/projects/validation.ts
import { z } from "zod"
export const createProjectSchema = z.object({
name: z.string().min(1, "Name is required").max(255),
description: z.string().max(1000).optional(),
status: z.enum(["active", "completed", "archived"]).default("active"),
})
export const updateProjectSchema = createProjectSchema.partial()
export type CreateProjectInput = z.infer<typeof createProjectSchema>
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>Step 3: Create Queries
// src/features/projects/queries.ts
import { db } from "@/lib/db"
import { projects } from "@/lib/db/schema"
import { eq, and, desc, ilike, sql } from "drizzle-orm"
// Types
export type Project = {
id: string
name: string
description: string | null
status: string
createdAt: Date
updatedAt: Date
}
export type ProjectListItem = Pick<Project, "id" | "name" | "status" | "createdAt">
// Queries
export async function listProjects(params: {
organizationId: string
page: number
pageSize: number
q?: string
status?: string
}) {
const { organizationId, page, pageSize, q, status } = params
const offset = (page - 1) * pageSize
const conditions = [eq(projects.organizationId, organizationId)]
if (q) {
conditions.push(ilike(projects.name, `%${q}%`))
}
if (status) {
conditions.push(eq(projects.status, status))
}
const [data, countResult] = await Promise.all([
db
.select({
id: projects.id,
name: projects.name,
status: projects.status,
createdAt: projects.createdAt,
})
.from(projects)
.where(and(...conditions))
.orderBy(desc(projects.createdAt))
.limit(pageSize)
.offset(offset),
db
.select({ count: sql<number>`count(*)` })
.from(projects)
.where(and(...conditions)),
])
const rowCount = Number(countResult[0]?.count ?? 0)
return {
data,
meta: {
page,
pageSize,
rowCount,
pageCount: Math.ceil(rowCount / pageSize),
},
}
}
export async function getProjectById(params: {
organizationId: string
id: string
}): Promise<Project | null> {
const result = await db
.select()
.from(projects)
.where(and(eq(projects.organizationId, params.organizationId), eq(projects.id, params.id)))
.limit(1)
return result[0] ?? null
}Step 4: Create Actions
// src/features/projects/actions.ts
import { db } from "@/lib/db"
import { projects } from "@/lib/db/schema"
import { eq, and } from "drizzle-orm"
import type { CreateProjectInput, UpdateProjectInput } from "./validation"
export async function createProject(params: { organizationId: string; data: CreateProjectInput }) {
const [result] = await db
.insert(projects)
.values({
organizationId: params.organizationId,
...params.data,
})
.returning({ id: projects.id })
return result
}
export async function updateProject(params: {
organizationId: string
id: string
data: UpdateProjectInput
}) {
const [result] = await db
.update(projects)
.set({
...params.data,
updatedAt: new Date(),
})
.where(and(eq(projects.organizationId, params.organizationId), eq(projects.id, params.id)))
.returning({ id: projects.id })
return result
}
export async function deleteProject(params: { organizationId: string; id: string }) {
await db
.delete(projects)
.where(and(eq(projects.organizationId, params.organizationId), eq(projects.id, params.id)))
}Step 5: Create Export Files
// src/features/projects/index.ts
// Client-safe exports
export type { Project, ProjectListItem } from "./queries"
export type { CreateProjectInput, UpdateProjectInput } from "./validation"// src/features/projects/server.ts
// Server-only exports
export * from "./queries"
export * from "./actions"
export * from "./validation"Step 6: Create Router
// src/server/routers/projects.ts
import { z } from "zod"
import { ORPCError } from "@orpc/server"
import { authedProcedure } from "../orpc"
import {
listProjects,
getProjectById,
createProject,
updateProject,
deleteProject,
createProjectSchema,
updateProjectSchema,
} from "@/features/projects/server"
const listInputSchema = z.object({
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(10),
q: z.string().optional(),
status: z.string().optional(),
})
export const projectsRouter = {
list: authedProcedure.input(listInputSchema).handler(async ({ input, context }) => {
return listProjects({
organizationId: context.organizationId,
page: input.page,
pageSize: input.pageSize,
...(input.q && { q: input.q }),
...(input.status && { status: input.status }),
})
}),
get: authedProcedure
.input(z.object({ id: z.string().uuid() }))
.handler(async ({ input, context }) => {
const project = await getProjectById({
organizationId: context.organizationId,
id: input.id,
})
if (!project) {
throw new ORPCError("NOT_FOUND", { message: "Project not found" })
}
return project
}),
create: authedProcedure.input(createProjectSchema).handler(async ({ input, context }) => {
return createProject({
organizationId: context.organizationId,
data: input,
})
}),
update: authedProcedure
.input(z.object({ id: z.string().uuid(), data: updateProjectSchema }))
.handler(async ({ input, context }) => {
const result = await updateProject({
organizationId: context.organizationId,
id: input.id,
data: input.data,
})
if (!result) {
throw new ORPCError("NOT_FOUND", { message: "Project not found" })
}
return result
}),
delete: authedProcedure
.input(z.object({ id: z.string().uuid() }))
.handler(async ({ input, context }) => {
await deleteProject({
organizationId: context.organizationId,
id: input.id,
})
return { success: true }
}),
}Step 7: Register Router
// src/server/routers/index.ts
import { projectsRouter } from "./projects"
export const appRouter = {
// ... existing routers
projects: projectsRouter,
}Step 8: Create List Page
// src/app/(dashboard)/projects/page.tsx
"use client"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { orpcUtils } from "@/lib/orpc/client"
import { useDataTableUrlState } from "@/hooks/use-data-table-url-state"
import {
DataTable,
DataTableToolbar,
DataTablePagination,
DataTableColumnHeader,
} from "@/components/data-table"
import { useReactTable, getCoreRowModel } from "@tanstack/react-table"
import type { ColumnDef } from "@tanstack/react-table"
import type { ProjectListItem } from "@/features/projects"
const columns: ColumnDef<ProjectListItem>[] = [
{
accessorKey: "name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
meta: { isPrimary: true },
},
{
accessorKey: "status",
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
},
{
accessorKey: "createdAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Created" />,
cell: ({ row }) => new Date(row.original.createdAt).toLocaleDateString(),
},
]
export default function ProjectsPage() {
const { page, pageSize, sort, q, sorting, pagination, onSortingChange, onPaginationChange } =
useDataTableUrlState({ defaultPageSize: 10 })
const { data, isLoading } = useQuery(
orpcUtils.projects.list.queryOptions({
input: { page, pageSize, q },
})
)
const table = useReactTable({
data: data?.data ?? [],
columns,
state: { sorting, pagination },
onSortingChange,
onPaginationChange,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualSorting: true,
pageCount: data?.meta.pageCount ?? -1,
})
return (
<div className="space-y-4">
<DataTableToolbar table={table} searchPlaceholder="Search projects..." />
<DataTable table={table} isLoading={isLoading} />
<DataTablePagination table={table} />
</div>
)
}Step 9: Verify
# Check TypeScript
npm run typecheck
# Run dev server
npm run dev
# Open http://localhost:3000/projectsCommon Mistakes
❌ Wrong: Passing organizationId from client
// Router input
.input(z.object({ organizationId: z.string(), ... }))✅ Correct: Get organizationId from context
// In handler
organizationId: context.organizationId❌ Wrong: Importing server.ts in client component
// In page.tsx
import { listProjects } from "@/features/projects/server" // ERROR!✅ Correct: Use oRPC client
const { data } = useQuery(orpcUtils.projects.list.queryOptions({ input: { page: 1 } }))❌ Wrong: Forgetting to register router
// Router exists but not in appRouter✅ Correct: Add to src/server/routers/index.ts
export const appRouter = {
projects: projectsRouter, // Add this
}Related
- Add Field to Entity - Extend this feature
- Add Filter - Add status filter
- DataTable - Customize the table