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

#FilePurpose
1src/lib/db/schema/<name>.tsDatabase table
2src/features/<name>/validation.tsZod schemas
3src/features/<name>/queries.tsRead operations
4src/features/<name>/actions.tsWrite operations
5src/features/<name>/index.tsClient exports
6src/features/<name>/server.tsServer exports
7src/server/routers/<name>.tsAPI router
8src/server/routers/index.tsRegister router
9src/app/(dashboard)/<name>/page.tsxList 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:push

Step 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/projects

Common 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
}

On this page