---
description: Complete example of implementing a full-stack feature
version: 1.0.0
lastUpdated: 2025-01-10
tags: [example, full-stack, tutorial, feature-development]
---

# Example: Full Stack Feature Implementation

This document provides a complete, real-world example of implementing a full-stack feature from database to UI.

---

## Feature: Project Tags

**Requirement**: Allow users to add tags to projects for better organization and filtering.

---

## Step 1: Database Schema

### File: `packages/database/src/schema.ts`

```typescript
import { pgTable, varchar, text, timestamp, index } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

// Tags table
export const tags = pgTable('tags', {
  id: varchar('id', { length: 191 }).primaryKey(), // ULID stored as varchar
  name: text('name').notNull(),
  color: text('color').notNull(), // Hex color code
  projectId: varchar('project_id', { length: 191 })
    .references(() => projects.id, { onDelete: 'cascade' })
    .notNull(),
  userId: varchar('user_id', { length: 191 })
    .references(() => users.id, { onDelete: 'cascade' })
    .notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => ({
  projectIdIdx: index('tags_project_id_idx').on(table.projectId),
  userIdIdx: index('tags_user_id_idx').on(table.userId),
  nameIdx: index('tags_name_idx').on(table.name),
}));

// Relations
export const tagsRelations = relations(tags, ({ one }) => ({
  project: one(projects, {
    fields: [tags.projectId],
    references: [projects.id],
  }),
  user: one(users, {
    fields: [tags.userId],
    references: [users.id],
  }),
}));

export const projectsRelations = relations(projects, ({ many }) => ({
  tags: many(tags),
  // ... other relations
}));

// Types
export type Tag = InferSelectModel<typeof tags>;
export type NewTag = InferInsertModel<typeof tags>;
```

### Generate and Apply Migration

```bash
cd packages/database
pnpm db:generate
# Review migrations/XXXX_add_tags.sql
pnpm db:migrate
pnpm db:studio # Verify in Drizzle Studio
```

---

## Step 2: Validation Schemas

### File: `packages/shared/src/validation/tags.ts`

```typescript
import { z } from 'zod';

// Validation schemas
export const createTagSchema = z.object({
  name: z.string().min(1).max(50).trim(),
  color: z.string().regex(/^#[0-9A-F]{6}$/i, 'Invalid hex color'),
  projectId: z.string().ulid(), // ULID validation
});

export const updateTagSchema = z.object({
  name: z.string().min(1).max(50).trim().optional(),
  color: z.string().regex(/^#[0-9A-F]{6}$/i, 'Invalid hex color').optional(),
});

export const deleteTagSchema = z.object({
  id: z.string().ulid(), // ULID validation
});

export const listTagsSchema = z.object({
  projectId: z.string().ulid().optional(), // ULID validation
  userId: z.string().ulid().optional(), // ULID validation
  limit: z.number().min(1).max(100).default(50),
  offset: z.number().min(0).default(0),
});

// Type inference
export type CreateTagInput = z.infer<typeof createTagSchema>;
export type UpdateTagInput = z.infer<typeof updateTagSchema>;
export type DeleteTagInput = z.infer<typeof deleteTagSchema>;
export type ListTagsInput = z.infer<typeof listTagsSchema>;
```

---

## Step 3: tRPC Router

### File: `packages/trpc/src/routers/tags.ts`

```typescript
import { router, protectedProcedure } from '../trpc';
import { tags } from '@izri/database/schema';
import { eq, and, desc } from 'drizzle-orm';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { ulid } from 'ulid';
import {
  createTagSchema,
  updateTagSchema,
  deleteTagSchema,
  listTagsSchema,
} from '@izri/shared/validation/tags';

export const tagsRouter = router({
  // List tags
  list: protectedProcedure
    .input(listTagsSchema)
    .query(async ({ ctx, input }) => {
      const conditions = [];
      
      if (input.projectId) {
        conditions.push(eq(tags.projectId, input.projectId));
      }
      
      if (input.userId) {
        conditions.push(eq(tags.userId, input.userId));
      } else {
        // Default to current user
        conditions.push(eq(tags.userId, ctx.user.id));
      }
      
      const tagsList = await ctx.db
        .select()
        .from(tags)
        .where(and(...conditions))
        .limit(input.limit)
        .offset(input.offset)
        .orderBy(desc(tags.createdAt));
      
      return tagsList;
    }),
  
  // Get single tag
  getById: protectedProcedure
    .input(z.object({ id: z.string().ulid() })) // ULID validation
    .query(async ({ ctx, input }) => {
      const tag = await ctx.db.query.tags.findFirst({
        where: eq(tags.id, input.id),
      });
      
      if (!tag) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'Tag not found',
        });
      }
      
      // Verify ownership
      if (tag.userId !== ctx.user.id) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Access denied',
        });
      }
      
      return tag;
    }),
  
  // Create tag
  create: protectedProcedure
    .input(createTagSchema)
    .mutation(async ({ ctx, input }) => {
      // Verify project ownership
      const project = await ctx.db.query.projects.findFirst({
        where: eq(projects.id, input.projectId),
      });
      
      if (!project) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'Project not found',
        });
      }
      
      if (project.userId !== ctx.user.id) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'You can only add tags to your own projects',
        });
      }
      
      // Check for duplicate tag name in project
      const existingTag = await ctx.db.query.tags.findFirst({
        where: and(
          eq(tags.projectId, input.projectId),
          eq(tags.name, input.name)
        ),
      });
      
      if (existingTag) {
        throw new TRPCError({
          code: 'CONFLICT',
          message: 'A tag with this name already exists in this project',
        });
      }
      
      const tagId = ulid(); // Generate ULID for new tag
      
      const [tag] = await ctx.db
        .insert(tags)
        .values({
          id: tagId,
          ...input,
          userId: ctx.user.id,
        })
        .returning();
      
      return tag;
    }),
  
  // Update tag
  update: protectedProcedure
    .input(z.object({
      id: z.string().ulid(), // ULID validation
      data: updateTagSchema,
    }))
    .mutation(async ({ ctx, input }) => {
      // Verify ownership
      const existingTag = await ctx.db.query.tags.findFirst({
        where: eq(tags.id, input.id),
      });
      
      if (!existingTag) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'Tag not found',
        });
      }
      
      if (existingTag.userId !== ctx.user.id) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Access denied',
        });
      }
      
      const [updatedTag] = await ctx.db
        .update(tags)
        .set({
          ...input.data,
          updatedAt: new Date(),
        })
        .where(eq(tags.id, input.id))
        .returning();
      
      return updatedTag;
    }),
  
  // Delete tag
  delete: protectedProcedure
    .input(deleteTagSchema)
    .mutation(async ({ ctx, input }) => {
      // Verify ownership
      const tag = await ctx.db.query.tags.findFirst({
        where: eq(tags.id, input.id),
      });
      
      if (!tag) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'Tag not found',
        });
      }
      
      if (tag.userId !== ctx.user.id) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Access denied',
        });
      }
      
      await ctx.db.delete(tags).where(eq(tags.id, input.id));
      
      return { success: true };
    }),
});
```

### Add to Root Router

```typescript
// File: packages/trpc/src/routers/index.ts
import { tagsRouter } from './tags';

export const appRouter = router({
  // ... existing routers
  tags: tagsRouter,
});
```

---

## Step 4: Frontend Hooks

### File: `apps/web/app/hooks/useTags.ts`

```typescript
import { trpc } from '~/lib/trpc';
import type { CreateTagInput, UpdateTagInput } from '@izri/shared/validation/tags';

// List tags for a project
export function useProjectTags(projectId: string) {
  return trpc.tags.list.useQuery(
    { projectId },
    { enabled: !!projectId }
  );
}

// List all user tags
export function useUserTags() {
  return trpc.tags.list.useQuery({});
}

// Get single tag
export function useTag(tagId: string) {
  return trpc.tags.getById.useQuery(
    { id: tagId },
    { enabled: !!tagId }
  );
}

// Create tag mutation
export function useCreateTag() {
  const utils = trpc.useUtils();
  
  return trpc.tags.create.useMutation({
    onSuccess: (newTag) => {
      // Invalidate project tags
      utils.tags.list.invalidate({ projectId: newTag.projectId });
      
      // Invalidate user tags
      utils.tags.list.invalidate({});
    },
  });
}

// Update tag mutation
export function useUpdateTag() {
  const utils = trpc.useUtils();
  
  return trpc.tags.update.useMutation({
    onSuccess: (updatedTag) => {
      // Invalidate specific tag
      utils.tags.getById.invalidate({ id: updatedTag.id });
      
      // Invalidate lists
      utils.tags.list.invalidate({ projectId: updatedTag.projectId });
      utils.tags.list.invalidate({});
    },
  });
}

// Delete tag mutation
export function useDeleteTag() {
  const utils = trpc.useUtils();
  
  return trpc.tags.delete.useMutation({
    onSuccess: () => {
      // Invalidate all tag queries
      utils.tags.invalidate();
    },
  });
}
```

---

## Step 5: UI Components

### File: `apps/web/app/components/features/TagBadge.tsx`

```typescript
import { Badge } from '~/components/ui/badge';
import { X } from 'lucide-react';
import type { Tag } from '@izri/database/schema';

interface TagBadgeProps {
  tag: Tag;
  onRemove?: () => void;
  removable?: boolean;
}

export function TagBadge({ tag, onRemove, removable = false }: TagBadgeProps) {
  return (
    <Badge
      style={{
        backgroundColor: tag.color,
        color: getContrastColor(tag.color),
      }}
      className="flex items-center gap-1"
    >
      {tag.name}
      {removable && onRemove && (
        <button
          onClick={(e) => {
            e.stopPropagation();
            onRemove();
          }}
          className="ml-1 hover:opacity-70"
          aria-label={`Remove ${tag.name} tag`}
        >
          <X className="h-3 w-3" />
        </button>
      )}
    </Badge>
  );
}

// Utility function to determine contrasting text color
function getContrastColor(hexColor: string): string {
  const r = parseInt(hexColor.slice(1, 3), 16);
  const g = parseInt(hexColor.slice(3, 5), 16);
  const b = parseInt(hexColor.slice(5, 7), 16);
  const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
  return luminance > 0.5 ? '#000000' : '#FFFFFF';
}
```

### File: `apps/web/app/components/features/TagManager.tsx`

```typescript
import { useState } from 'react';
import { Button } from '~/components/ui/button';
import { Input } from '~/components/ui/input';
import { Label } from '~/components/ui/label';
import { TagBadge } from './TagBadge';
import { useProjectTags, useCreateTag, useDeleteTag } from '~/hooks/useTags';
import { Plus } from 'lucide-react';

interface TagManagerProps {
  projectId: string;
}

const PRESET_COLORS = [
  '#EF4444', // red
  '#F59E0B', // orange
  '#10B981', // green
  '#3B82F6', // blue
  '#8B5CF6', // purple
  '#EC4899', // pink
];

export function TagManager({ projectId }: TagManagerProps) {
  const [isAdding, setIsAdding] = useState(false);
  const [tagName, setTagName] = useState('');
  const [selectedColor, setSelectedColor] = useState(PRESET_COLORS[0]);
  
  const { data: tags, isLoading } = useProjectTags(projectId);
  const createTag = useCreateTag();
  const deleteTag = useDeleteTag();
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    if (!tagName.trim()) return;
    
    try {
      await createTag.mutateAsync({
        name: tagName.trim(),
        color: selectedColor,
        projectId,
      });
      
      // Reset form
      setTagName('');
      setSelectedColor(PRESET_COLORS[0]);
      setIsAdding(false);
    } catch (error) {
      // Error handling (show toast, etc.)
      console.error('Failed to create tag:', error);
    }
  };
  
  if (isLoading) {
    return <div>Loading tags...</div>;
  }
  
  return (
    <div className="space-y-4">
      <div className="flex items-center justify-between">
        <h3 className="text-lg font-semibold">Tags</h3>
        {!isAdding && (
          <Button
            variant="outline"
            size="sm"
            onClick={() => setIsAdding(true)}
          >
            <Plus className="h-4 w-4 mr-1" />
            Add Tag
          </Button>
        )}
      </div>
      
      {/* Tag list */}
      <div className="flex flex-wrap gap-2">
        {tags?.map((tag) => (
          <TagBadge
            key={tag.id}
            tag={tag}
            removable
            onRemove={() => deleteTag.mutate({ id: tag.id })}
          />
        ))}
        {tags?.length === 0 && !isAdding && (
          <p className="text-sm text-gray-500">No tags yet</p>
        )}
      </div>
      
      {/* Add tag form */}
      {isAdding && (
        <form onSubmit={handleSubmit} className="space-y-3 border p-4 rounded">
          <div>
            <Label htmlFor="tag-name">Tag Name</Label>
            <Input
              id="tag-name"
              value={tagName}
              onChange={(e) => setTagName(e.target.value)}
              placeholder="e.g., Frontend, Bug, Feature"
              maxLength={50}
              autoFocus
            />
          </div>
          
          <div>
            <Label>Color</Label>
            <div className="flex gap-2 mt-2">
              {PRESET_COLORS.map((color) => (
                <button
                  key={color}
                  type="button"
                  onClick={() => setSelectedColor(color)}
                  className={`w-8 h-8 rounded-full border-2 ${
                    selectedColor === color
                      ? 'border-gray-900 scale-110'
                      : 'border-gray-300'
                  }`}
                  style={{ backgroundColor: color }}
                  aria-label={`Select ${color}`}
                />
              ))}
            </div>
          </div>
          
          <div className="flex gap-2">
            <Button
              type="submit"
              disabled={!tagName.trim() || createTag.isPending}
            >
              {createTag.isPending ? 'Creating...' : 'Create Tag'}
            </Button>
            <Button
              type="button"
              variant="outline"
              onClick={() => {
                setIsAdding(false);
                setTagName('');
              }}
            >
              Cancel
            </Button>
          </div>
        </form>
      )}
    </div>
  );
}
```

---

## Step 6: Integration in Project Page

### File: `apps/web/app/routes/projects/$id.tsx`

```typescript
import { useParams } from 'react-router-dom';
import { useProject } from '~/hooks/useProject';
import { TagManager } from '~/components/features/TagManager';

export default function ProjectDetailPage() {
  const { id } = useParams<{ id: string }>();
  const { project, isLoading } = useProject(id!);
  
  if (isLoading) {
    return <div>Loading...</div>;
  }
  
  if (!project) {
    return <div>Project not found</div>;
  }
  
  return (
    <div className="container mx-auto py-8">
      <h1 className="text-3xl font-bold mb-8">{project.name}</h1>
      
      {/* ... other project details ... */}
      
      <div className="mt-8">
        <TagManager projectId={project.id} />
      </div>
    </div>
  );
}
```

---

## Step 7: Tests

### File: `packages/trpc/src/routers/__tests__/tags.test.ts`

```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestContext } from '../../test-utils';
import { appRouter } from '../../root';

describe('tags router', () => {
  let ctx: TestContext;
  let caller: ReturnType<typeof appRouter.createCaller>;
  let projectId: string;
  
  beforeEach(async () => {
    ctx = await createTestContext({ authenticated: true });
    caller = appRouter.createCaller(ctx);
    
    // Create a test project
    const project = await caller.projects.create({
      name: 'Test Project',
    });
    projectId = project.id;
  });
  
  describe('create', () => {
    it('should create a tag', async () => {
      const tag = await caller.tags.create({
        name: 'Frontend',
        color: '#3B82F6',
        projectId,
      });
      
      expect(tag).toMatchObject({
        name: 'Frontend',
        color: '#3B82F6',
        projectId,
        userId: ctx.user.id,
      });
    });
    
    it('should not allow duplicate tag names in same project', async () => {
      await caller.tags.create({
        name: 'Frontend',
        color: '#3B82F6',
        projectId,
      });
      
      await expect(
        caller.tags.create({
          name: 'Frontend',
          color: '#EF4444',
          projectId,
        })
      ).rejects.toThrow('CONFLICT');
    });
    
    it('should validate color format', async () => {
      await expect(
        caller.tags.create({
          name: 'Frontend',
          color: 'invalid',
          projectId,
        })
      ).rejects.toThrow();
    });
  });
  
  describe('list', () => {
    it('should list tags for a project', async () => {
      await caller.tags.create({
        name: 'Frontend',
        color: '#3B82F6',
        projectId,
      });
      
      await caller.tags.create({
        name: 'Backend',
        color: '#10B981',
        projectId,
      });
      
      const tags = await caller.tags.list({ projectId });
      
      expect(tags).toHaveLength(2);
    });
    
    it('should only show user\'s own tags', async () => {
      // Create tag as user1
      await caller.tags.create({
        name: 'Frontend',
        color: '#3B82F6',
        projectId,
      });
      
      // Query as user2
      const ctx2 = await createTestContext({ authenticated: true });
      const caller2 = appRouter.createCaller(ctx2);
      
      const tags = await caller2.tags.list({ projectId });
      
      expect(tags).toHaveLength(0);
    });
  });
  
  describe('delete', () => {
    it('should delete a tag', async () => {
      const tag = await caller.tags.create({
        name: 'Frontend',
        color: '#3B82F6',
        projectId,
      });
      
      await caller.tags.delete({ id: tag.id });
      
      await expect(
        caller.tags.getById({ id: tag.id })
      ).rejects.toThrow('NOT_FOUND');
    });
    
    it('should not allow deleting other user\'s tags', async () => {
      const tag = await caller.tags.create({
        name: 'Frontend',
        color: '#3B82F6',
        projectId,
      });
      
      const ctx2 = await createTestContext({ authenticated: true });
      const caller2 = appRouter.createCaller(ctx2);
      
      await expect(
        caller2.tags.delete({ id: tag.id })
      ).rejects.toThrow('FORBIDDEN');
    });
  });
});
```

---

## Summary

This example demonstrates:

1. ✅ **Database schema** with proper relations and indexes
2. ✅ **Validation schemas** using Zod
3. ✅ **tRPC router** with full CRUD operations
4. ✅ **Authorization checks** (ownership, permissions)
5. ✅ **Frontend hooks** for data fetching and mutations
6. ✅ **React components** with proper UX
7. ✅ **Comprehensive tests** covering edge cases

**Key Patterns Used**:
- Type-safe end-to-end with tRPC
- Zod schemas for validation
- Drizzle ORM for database
- React Query for state management
- Component composition
- Custom hooks for logic reuse
- Proper error handling

---

**Version**: 1.0.0  
**Last Updated**: 2025-01-10