Docs /ai-agents/examples/full-stack-feature

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

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

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

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

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

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

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

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

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

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

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

Reading this with an agent? /docs/ai-agents/examples/full-stack-feature.md serves the raw markdown.

All docs