GameCraftGameCraft

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 project
  • text - Text fields (name, description)
  • .notNull() - This field is required
  • .references() - Links to users table
  • timestamp - 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 line

Run Migration

Create and apply the database migration:

# Generate migration file
pnpm db:generate

# Apply to database
pnpm db:migrate

You 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

  1. Start dev server: pnpm dev
  2. Login to dashboard: http://localhost:3000/dashboard
  3. Visit: http://localhost:3000/projects
  4. Create a project!
  5. See it appear in the list
  6. 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:

  1. Edit functionality

    • Add an edit button
    • Show edit form
    • Call trpc.projects.update.useMutation()
  2. Project details page

    • Create src/app/(dashboard)/projects/[id]/page.tsx
    • Use trpc.projects.get.useQuery({ id })
  3. Filter and search

    • Add search input
    • Filter projects by name
    • Sort by date/name
  4. 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 dev

Want More Tutorials?


Congratulations! You built a complete feature from scratch. Keep building! 🚀

On this page