Create a New Feature
Step-by-step tutorial to add a "Projects" feature from scratch
Tutorial: Create a New Feature
Let's build a complete "Projects" feature from scratch! You'll learn:
- ✅ Create database table
- ✅ Build tRPC API
- ✅ Create UI components
- ✅ Add to dashboard
Time: 20-30 minutes
Difficulty: Beginner
Follow along: Copy-paste the code below. We'll explain each step!
What We're Building
A "Projects" feature where users can:
- Create new projects
- List all their projects
- Edit project details
- Delete projects
Create Database Schema
Create file: src/db/schema/projects.ts
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { users } from './users';
export const projects = pgTable('projects', {
// ID - unique identifier for each project
id: uuid('id').defaultRandom().primaryKey(),
// Name - project title
name: text('name').notNull(),
// Description - optional details
description: text('description'),
// Owner - who created this project
ownerId: text('owner_id')
.references(() => users.id)
.notNull(),
// Timestamps - when created/updated
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});What this means:
uuid- Random ID for each projecttext- Text fields (name, description).notNull()- This field is required.references()- Links to users tabletimestamp- Date/time fields
Export Schema
Add to src/db/schema/index.ts:
export * from './users';
export * from './tasks';
export * from './posts';
export * from './projects'; // ← Add this lineRun Migration
Create and apply the database migration:
# Generate migration file
pnpm db:generate
# Apply to database
pnpm db:migrateYou should see: ✓ Migration applied successfully
Create tRPC Router
Create file: src/server/routers/projects.ts
import { z } from 'zod';
import { createTRPCRouter, protectedProcedure } from '../trpc';
import { projects } from '~/db/schema/projects';
import { eq } from 'drizzle-orm';
import { TRPCError } from '@trpc/server';
export const projectsRouter = createTRPCRouter({
// List all user's projects
list: protectedProcedure.query(async ({ ctx }) => {
return ctx.db
.select()
.from(projects)
.where(eq(projects.ownerId, ctx.session.user.id))
.orderBy(projects.createdAt);
}),
// Get single project
get: protectedProcedure
.input(z.object({
id: z.string().uuid(),
}))
.query(async ({ ctx, input }) => {
const [project] = await ctx.db
.select()
.from(projects)
.where(eq(projects.id, input.id))
.limit(1);
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project not found',
});
}
// Check ownership
if (project.ownerId !== ctx.session.user.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Not your project',
});
}
return project;
}),
// Create new project
create: protectedProcedure
.input(z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
}))
.mutation(async ({ ctx, input }) => {
const [project] = await ctx.db
.insert(projects)
.values({
name: input.name,
description: input.description,
ownerId: ctx.session.user.id,
})
.returning();
return project;
}),
// Update project
update: protectedProcedure
.input(z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional(),
}))
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
// Check ownership first
const [existing] = await ctx.db
.select()
.from(projects)
.where(eq(projects.id, id))
.limit(1);
if (!existing || existing.ownerId !== ctx.session.user.id) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
// Update
const [updated] = await ctx.db
.update(projects)
.set({ ...data, updatedAt: new Date() })
.where(eq(projects.id, id))
.returning();
return updated;
}),
// Delete project
delete: protectedProcedure
.input(z.object({
id: z.string().uuid(),
}))
.mutation(async ({ ctx, input }) => {
// Check ownership
const [existing] = await ctx.db
.select()
.from(projects)
.where(eq(projects.id, input.id))
.limit(1);
if (!existing || existing.ownerId !== ctx.session.user.id) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
// Delete
await ctx.db
.delete(projects)
.where(eq(projects.id, input.id));
return { success: true };
}),
});Register Router
Add to src/server/routers/index.ts:
import { projectsRouter } from './projects';
export const appRouter = createTRPCRouter({
tasks: tasksRouter,
posts: postsRouter,
projects: projectsRouter, // ← Add this
});Create UI Component
Create file: src/components/dashboard/projects-list.tsx
'use client';
import { trpc } from '~/lib/trpc/client';
import { useState } from 'react';
export function ProjectsList() {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
// Fetch projects
const { data: projects, isLoading } = trpc.projects.list.useQuery();
// Create mutation
const utils = trpc.useUtils();
const createProject = trpc.projects.create.useMutation({
onSuccess: () => {
// Refresh list
utils.projects.list.invalidate();
// Clear form
setName('');
setDescription('');
},
});
// Delete mutation
const deleteProject = trpc.projects.delete.useMutation({
onSuccess: () => {
utils.projects.list.invalidate();
},
});
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
await createProject.mutateAsync({ name, description });
}
if (isLoading) {
return <div>Loading projects...</div>;
}
return (
<div className="space-y-6">
{/* Create Form */}
<form onSubmit={handleCreate} className="space-y-4">
<h2 className="text-xl font-bold">Create Project</h2>
<div>
<label className="block text-sm font-medium mb-1">
Project Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border rounded"
placeholder="My Awesome Project"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 border rounded"
placeholder="What is this project about?"
rows={3}
/>
</div>
<button
type="submit"
disabled={createProject.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
{createProject.isPending ? 'Creating...' : 'Create Project'}
</button>
</form>
{/* Projects List */}
<div>
<h2 className="text-xl font-bold mb-4">Your Projects</h2>
{projects?.length === 0 ? (
<p className="text-gray-500">No projects yet. Create one above!</p>
) : (
<div className="grid gap-4">
{projects?.map((project) => (
<div
key={project.id}
className="p-4 border rounded hover:shadow-md"
>
<h3 className="font-semibold text-lg">{project.name}</h3>
{project.description && (
<p className="text-gray-600 mt-1">{project.description}</p>
)}
<div className="mt-3 flex gap-2">
<button
onClick={() => deleteProject.mutate({ id: project.id })}
className="text-red-600 hover:text-red-700"
>
Delete
</button>
</div>
<p className="text-xs text-gray-400 mt-2">
Created: {new Date(project.createdAt).toLocaleDateString()}
</p>
</div>
))}
</div>
)}
</div>
</div>
);
}Add to Dashboard
Create file: src/app/(dashboard)/projects/page.tsx
import { ProjectsList } from '~/components/dashboard/projects-list';
export default function ProjectsPage() {
return (
<div className="container mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">Projects</h1>
<ProjectsList />
</div>
);
}Test Your Feature
- Start dev server:
pnpm dev - Login to dashboard:
http://localhost:3000/dashboard - Visit:
http://localhost:3000/projects - Create a project!
- See it appear in the list
- Delete it
It works! 🎉
What You Learned
✅ Database - Create tables with Drizzle ORM
✅ API - Build type-safe endpoints with tRPC
✅ UI - Create React components with state
✅ Integration - Connect frontend to backend
Next Steps
Add More Features
Now that you know the pattern, try:
-
Edit functionality
- Add an edit button
- Show edit form
- Call
trpc.projects.update.useMutation()
-
Project details page
- Create
src/app/(dashboard)/projects/[id]/page.tsx - Use
trpc.projects.get.useQuery({ id })
- Create
-
Filter and search
- Add search input
- Filter projects by name
- Sort by date/name
-
Pagination
- Limit to 10 per page
- Add "Load more" button
- Use
useInfiniteQuery
Common Issues
"Table doesn't exist"
Fix: Run migrations:
pnpm db:migrate"Cannot read property of undefined"
Fix: Check you're logged in:
- protectedProcedure requires authentication
- Sign in at
/sign-in
Type errors
Fix: Restart dev server:
# Stop with Ctrl+C, then:
pnpm devWant More Tutorials?
Congratulations! You built a complete feature from scratch. Keep building! 🚀