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:
- ✅ Database schema with proper relations and indexes
- ✅ Validation schemas using Zod
- ✅ tRPC router with full CRUD operations
- ✅ Authorization checks (ownership, permissions)
- ✅ Frontend hooks for data fetching and mutations
- ✅ React components with proper UX
- ✅ 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