Full-Stack TypeScript Project - Intermediate CLAUDE.md Example

Project Overview

This is a full-stack TypeScript application with:

  • Backend: Node.js + Express + PostgreSQL + Prisma ORM
  • Frontend: Next.js 14 with App Router
  • Testing: Jest + Supertest for API, React Testing Library for UI
  • API Documentation: OpenAPI/Swagger
  • Authentication: JWT with refresh tokens
  • Real-time: Socket.io for live updates

Shared Types Strategy

Monorepo Type Sharing

// packages/shared/types/api.ts
export interface User {
  id: string;
  email: string;
  name: string;
  role: UserRole;
  createdAt: Date;
  updatedAt: Date;
}
 
export enum UserRole {
  USER = 'USER',
  ADMIN = 'ADMIN',
  MODERATOR = 'MODERATOR'
}
 
export interface ApiResponse<T> {
  success: boolean;
  data?: T;
  error?: {
    code: string;
    message: string;
    details?: unknown;
  };
  meta?: {
    page?: number;
    totalPages?: number;
    totalItems?: number;
  };
}
 
// Type guards
export function isApiError<T>(response: ApiResponse<T>): response is ApiResponse<T> & { error: NonNullable<ApiResponse<T>['error']> } {
  return !response.success && response.error !== undefined;
}

Backend Patterns

Express with TypeScript

// src/server.ts
import express, { Request, Response, NextFunction } from 'express';
import { PrismaClient } from '@prisma/client';
import { z } from 'zod';
 
// Extend Express Request type
interface AuthenticatedRequest extends Request {
  user?: {
    id: string;
    email: string;
    role: UserRole;
  };
}
 
// Global error handler with proper typing
class AppError extends Error {
  constructor(
    public statusCode: number,
    public code: string,
    message: string,
    public isOperational = true
  ) {
    super(message);
    Object.setPrototypeOf(this, AppError.prototype);
  }
}
 
// Async handler wrapper to avoid try-catch everywhere
const asyncHandler = <T extends Request = Request>(
  fn: (req: T, res: Response, next: NextFunction) => Promise<void>
) => {
  return (req: T, res: Response, next: NextFunction) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
};

API Route with Validation

// src/routes/users.ts
import { Router } from 'express';
import { z } from 'zod';
import { validateRequest } from '../middleware/validation';
 
const router = Router();
 
// Validation schemas
const createUserSchema = z.object({
  body: z.object({
    email: z.string().email(),
    password: z.string().min(8).regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/),
    name: z.string().min(2).max(100),
  }),
});
 
const getUsersQuerySchema = z.object({
  query: z.object({
    page: z.coerce.number().int().positive().default(1),
    limit: z.coerce.number().int().positive().max(100).default(20),
    sortBy: z.enum(['name', 'email', 'createdAt']).default('createdAt'),
    sortOrder: z.enum(['asc', 'desc']).default('desc'),
    search: z.string().optional(),
  }),
});
 
// Route handlers with proper typing
router.post(
  '/users',
  validateRequest(createUserSchema),
  asyncHandler(async (req, res) => {
    const { email, password, name } = req.body;
    
    // Business logic with Prisma
    const user = await prisma.user.create({
      data: {
        email,
        password: await hashPassword(password),
        name,
      },
      select: {
        id: true,
        email: true,
        name: true,
        role: true,
        createdAt: true,
      },
    });
 
    res.status(201).json({
      success: true,
      data: user,
    });
  })
);
 
router.get(
  '/users',
  authenticate,
  authorize([UserRole.ADMIN]),
  validateRequest(getUsersQuerySchema),
  asyncHandler(async (req: AuthenticatedRequest, res) => {
    const { page, limit, sortBy, sortOrder, search } = req.query;
    
    const where = search
      ? {
          OR: [
            { email: { contains: search, mode: 'insensitive' } },
            { name: { contains: search, mode: 'insensitive' } },
          ],
        }
      : {};
 
    const [users, total] = await prisma.$transaction([
      prisma.user.findMany({
        where,
        skip: (page - 1) * limit,
        take: limit,
        orderBy: { [sortBy]: sortOrder },
        select: {
          id: true,
          email: true,
          name: true,
          role: true,
          createdAt: true,
          updatedAt: true,
        },
      }),
      prisma.user.count({ where }),
    ]);
 
    res.json({
      success: true,
      data: users,
      meta: {
        page,
        totalPages: Math.ceil(total / limit),
        totalItems: total,
      },
    });
  })
);

Testing Backend

// src/routes/__tests__/users.test.ts
import request from 'supertest';
import { app } from '../../app';
import { prisma } from '../../lib/prisma';
import { generateAuthToken } from '../../lib/auth';
 
describe('Users API', () => {
  let adminToken: string;
  let userToken: string;
 
  beforeAll(async () => {
    // Seed test data
    const admin = await prisma.user.create({
      data: {
        email: 'admin@test.com',
        password: await hashPassword('Admin123!'),
        name: 'Admin User',
        role: UserRole.ADMIN,
      },
    });
 
    adminToken = generateAuthToken(admin);
  });
 
  afterAll(async () => {
    await prisma.user.deleteMany();
    await prisma.$disconnect();
  });
 
  describe('POST /api/users', () => {
    it('should create a new user with valid data', async () => {
      const newUser = {
        email: 'newuser@test.com',
        password: 'ValidPass123!',
        name: 'New User',
      };
 
      const response = await request(app)
        .post('/api/users')
        .send(newUser)
        .expect(201);
 
      expect(response.body).toMatchObject({
        success: true,
        data: {
          email: newUser.email,
          name: newUser.name,
          role: UserRole.USER,
        },
      });
 
      // Verify in database
      const dbUser = await prisma.user.findUnique({
        where: { email: newUser.email },
      });
      expect(dbUser).toBeTruthy();
    });
 
    it('should validate email format', async () => {
      const response = await request(app)
        .post('/api/users')
        .send({
          email: 'invalid-email',
          password: 'ValidPass123!',
          name: 'Test User',
        })
        .expect(400);
 
      expect(response.body).toMatchObject({
        success: false,
        error: {
          code: 'VALIDATION_ERROR',
          message: expect.stringContaining('email'),
        },
      });
    });
  });
 
  describe('GET /api/users', () => {
    it('should require authentication', async () => {
      await request(app)
        .get('/api/users')
        .expect(401);
    });
 
    it('should require admin role', async () => {
      await request(app)
        .get('/api/users')
        .set('Authorization', `Bearer ${userToken}`)
        .expect(403);
    });
 
    it('should return paginated users for admin', async () => {
      const response = await request(app)
        .get('/api/users?page=1&limit=10')
        .set('Authorization', `Bearer ${adminToken}`)
        .expect(200);
 
      expect(response.body).toMatchObject({
        success: true,
        data: expect.arrayContaining([
          expect.objectContaining({
            id: expect.any(String),
            email: expect.any(String),
            name: expect.any(String),
          }),
        ]),
        meta: {
          page: 1,
          totalPages: expect.any(Number),
          totalItems: expect.any(Number),
        },
      });
    });
  });
});

Frontend Patterns

Next.js App Router with TypeScript

// app/api/client.ts
import { ApiResponse, isApiError } from '@company/shared';
 
class ApiClient {
  private baseUrl: string;
  private headers: HeadersInit;
 
  constructor(baseUrl: string = process.env.NEXT_PUBLIC_API_URL!) {
    this.baseUrl = baseUrl;
    this.headers = {
      'Content-Type': 'application/json',
    };
  }
 
  private async request<T>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<T> {
    const url = `${this.baseUrl}${endpoint}`;
    
    const config: RequestInit = {
      ...options,
      headers: {
        ...this.headers,
        ...options.headers,
      },
    };
 
    // Add auth token if available
    if (typeof window !== 'undefined') {
      const token = localStorage.getItem('authToken');
      if (token) {
        config.headers = {
          ...config.headers,
          Authorization: `Bearer ${token}`,
        };
      }
    }
 
    const response = await fetch(url, config);
    const data: ApiResponse<T> = await response.json();
 
    if (!response.ok || isApiError(data)) {
      throw new ApiError(
        data.error?.message || 'An error occurred',
        data.error?.code || 'UNKNOWN_ERROR',
        response.status
      );
    }
 
    return data.data!;
  }
 
  // Typed methods
  async get<T>(endpoint: string): Promise<T> {
    return this.request<T>(endpoint, { method: 'GET' });
  }
 
  async post<T>(endpoint: string, data: unknown): Promise<T> {
    return this.request<T>(endpoint, {
      method: 'POST',
      body: JSON.stringify(data),
    });
  }
 
  async put<T>(endpoint: string, data: unknown): Promise<T> {
    return this.request<T>(endpoint, {
      method: 'PUT',
      body: JSON.stringify(data),
    });
  }
 
  async delete<T>(endpoint: string): Promise<T> {
    return this.request<T>(endpoint, { method: 'DELETE' });
  }
}
 
export const apiClient = new ApiClient();

React Query Integration

// app/hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { User, CreateUserDto } from '@company/shared';
import { apiClient } from '../api/client';
 
// Query keys factory
export const userKeys = {
  all: ['users'] as const,
  lists: () => [...userKeys.all, 'list'] as const,
  list: (filters: string) => [...userKeys.lists(), { filters }] as const,
  details: () => [...userKeys.all, 'detail'] as const,
  detail: (id: string) => [...userKeys.details(), id] as const,
};
 
// Hooks
export function useUsers(params?: {
  page?: number;
  limit?: number;
  search?: string;
}) {
  const queryString = new URLSearchParams(
    params as Record<string, string>
  ).toString();
 
  return useQuery({
    queryKey: userKeys.list(queryString),
    queryFn: () => apiClient.get<User[]>(`/users?${queryString}`),
    staleTime: 5 * 60 * 1000, // 5 minutes
    gcTime: 10 * 60 * 1000, // 10 minutes
  });
}
 
export function useUser(id: string) {
  return useQuery({
    queryKey: userKeys.detail(id),
    queryFn: () => apiClient.get<User>(`/users/${id}`),
    enabled: !!id,
  });
}
 
export function useCreateUser() {
  const queryClient = useQueryClient();
 
  return useMutation({
    mutationFn: (data: CreateUserDto) => 
      apiClient.post<User>('/users', data),
    onSuccess: () => {
      // Invalidate and refetch users list
      queryClient.invalidateQueries({ queryKey: userKeys.lists() });
    },
    onError: (error: ApiError) => {
      // Handle error with toast or notification
      console.error('Failed to create user:', error.message);
    },
  });
}

Server Components with Error Handling

// app/users/page.tsx
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { UsersList } from './UsersList';
import { UsersLoading } from './UsersLoading';
import { UsersError } from './UsersError';
 
interface PageProps {
  searchParams: {
    page?: string;
    search?: string;
  };
}
 
export default function UsersPage({ searchParams }: PageProps) {
  const page = Number(searchParams.page) || 1;
  const search = searchParams.search || '';
 
  return (
    <div className="container mx-auto py-8">
      <h1 className="text-3xl font-bold mb-8">Users Management</h1>
      
      <ErrorBoundary
        FallbackComponent={UsersError}
        onReset={() => window.location.reload()}
      >
        <Suspense fallback={<UsersLoading />}>
          <UsersList page={page} search={search} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}
 
// Server Component
async function UsersList({ page, search }: { page: number; search: string }) {
  const users = await fetchUsers({ page, search });
  
  return (
    <div className="space-y-4">
      {users.map((user) => (
        <UserCard key={user.id} user={user} />
      ))}
    </div>
  );
}

Real-time Features with Socket.io

Server-side Socket.io with TypeScript

// src/socket/index.ts
import { Server as HttpServer } from 'http';
import { Server, Socket } from 'socket.io';
import { verifyToken } from '../lib/auth';
import { prisma } from '../lib/prisma';
 
interface SocketWithAuth extends Socket {
  userId?: string;
  role?: UserRole;
}
 
export function initializeSocketServer(httpServer: HttpServer) {
  const io = new Server(httpServer, {
    cors: {
      origin: process.env.CLIENT_URL,
      credentials: true,
    },
  });
 
  // Authentication middleware
  io.use(async (socket: SocketWithAuth, next) => {
    try {
      const token = socket.handshake.auth.token;
      const payload = await verifyToken(token);
      
      socket.userId = payload.userId;
      socket.role = payload.role;
      
      next();
    } catch (error) {
      next(new Error('Authentication failed'));
    }
  });
 
  io.on('connection', (socket: SocketWithAuth) => {
    console.log(`User ${socket.userId} connected`);
 
    // Join user's personal room
    socket.join(`user:${socket.userId}`);
 
    // Join role-based rooms
    if (socket.role === UserRole.ADMIN) {
      socket.join('admins');
    }
 
    // Handle events
    socket.on('user:update', async (data: Partial<User>) => {
      try {
        // Validate permissions
        if (socket.userId !== data.id && socket.role !== UserRole.ADMIN) {
          throw new Error('Unauthorized');
        }
 
        // Update user
        const updatedUser = await prisma.user.update({
          where: { id: data.id },
          data: {
            name: data.name,
            // ... other fields
          },
        });
 
        // Emit to relevant rooms
        io.to(`user:${data.id}`).emit('user:updated', updatedUser);
        io.to('admins').emit('user:updated', updatedUser);
      } catch (error) {
        socket.emit('error', {
          message: error.message,
          code: 'UPDATE_FAILED',
        });
      }
    });
 
    socket.on('disconnect', () => {
      console.log(`User ${socket.userId} disconnected`);
    });
  });
 
  return io;
}

Client-side Socket.io Hook

// app/hooks/useSocket.ts
import { useEffect, useRef, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
import { useAuth } from './useAuth';
 
type SocketEvents = {
  'user:updated': (user: User) => void;
  'notification:new': (notification: Notification) => void;
  'error': (error: { message: string; code: string }) => void;
};
 
export function useSocket() {
  const socketRef = useRef<Socket | null>(null);
  const { token } = useAuth();
 
  useEffect(() => {
    if (!token) return;
 
    socketRef.current = io(process.env.NEXT_PUBLIC_SOCKET_URL!, {
      auth: { token },
      reconnection: true,
      reconnectionDelay: 1000,
      reconnectionAttempts: 5,
    });
 
    const socket = socketRef.current;
 
    socket.on('connect', () => {
      console.log('Socket connected');
    });
 
    socket.on('disconnect', (reason) => {
      console.log('Socket disconnected:', reason);
    });
 
    socket.on('error', (error) => {
      console.error('Socket error:', error);
    });
 
    return () => {
      socket.disconnect();
    };
  }, [token]);
 
  const on = useCallback(<K extends keyof SocketEvents>(
    event: K,
    handler: SocketEvents[K]
  ) => {
    if (!socketRef.current) return;
    socketRef.current.on(event, handler);
 
    return () => {
      socketRef.current?.off(event, handler);
    };
  }, []);
 
  const emit = useCallback((event: string, data: any) => {
    if (!socketRef.current) return;
    socketRef.current.emit(event, data);
  }, []);
 
  return { on, emit, socket: socketRef.current };
}
 
// Usage in component
function UserProfile({ userId }: { userId: string }) {
  const { user, refetch } = useUser(userId);
  const { on } = useSocket();
 
  useEffect(() => {
    const unsubscribe = on('user:updated', (updatedUser) => {
      if (updatedUser.id === userId) {
        refetch();
      }
    });
 
    return unsubscribe;
  }, [userId, on, refetch]);
 
  return <div>{/* User profile UI */}</div>;
}

Testing Strategies

Integration Testing

// __tests__/integration/user-flow.test.ts
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { UsersPage } from '../../app/users/page';
 
// Mock server
const server = setupServer(
  rest.get('/api/users', (req, res, ctx) => {
    return res(
      ctx.json({
        success: true,
        data: [
          {
            id: '1',
            email: 'user1@test.com',
            name: 'User One',
            role: 'USER',
          },
        ],
        meta: {
          page: 1,
          totalPages: 1,
          totalItems: 1,
        },
      })
    );
  })
);
 
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
 
describe('User Management Flow', () => {
  it('should display users and handle interactions', async () => {
    const queryClient = new QueryClient({
      defaultOptions: {
        queries: { retry: false },
      },
    });
 
    render(
      <QueryClientProvider client={queryClient}>
        <UsersPage searchParams={{}} />
      </QueryClientProvider>
    );
 
    // Wait for users to load
    await waitFor(() => {
      expect(screen.getByText('User One')).toBeInTheDocument();
    });
 
    // Test search functionality
    const searchInput = screen.getByPlaceholderText('Search users...');
    await userEvent.type(searchInput, 'test');
 
    // Mock search request
    server.use(
      rest.get('/api/users', (req, res, ctx) => {
        const search = req.url.searchParams.get('search');
        expect(search).toBe('test');
        
        return res(ctx.json({
          success: true,
          data: [],
          meta: { page: 1, totalPages: 0, totalItems: 0 },
        }));
      })
    );
 
    await waitFor(() => {
      expect(screen.getByText('No users found')).toBeInTheDocument();
    });
  });
});

Performance Optimization

Database Query Optimization

// Bad: N+1 query problem
const users = await prisma.user.findMany();
for (const user of users) {
  const posts = await prisma.post.findMany({
    where: { authorId: user.id },
  });
  user.posts = posts;
}
 
// Good: Single query with includes
const users = await prisma.user.findMany({
  include: {
    posts: {
      select: {
        id: true,
        title: true,
        publishedAt: true,
      },
      orderBy: { publishedAt: 'desc' },
      take: 5,
    },
    _count: {
      select: { posts: true },
    },
  },
});

Frontend Bundle Optimization

// next.config.js
module.exports = {
  experimental: {
    optimizePackageImports: ['@company/ui', 'lodash-es'],
  },
  
  webpack: (config, { isServer }) => {
    if (!isServer) {
      // Replace heavy libraries with lighter alternatives
      config.resolve.alias = {
        ...config.resolve.alias,
        'moment': 'dayjs',
      };
    }
    return config;
  },
};
 
// Use dynamic imports for heavy components
const HeavyChart = dynamic(() => import('./HeavyChart'), {
  loading: () => <ChartSkeleton />,
  ssr: false,
});

Security Best Practices

Input Validation and Sanitization

// Always validate and sanitize inputs
import DOMPurify from 'isomorphic-dompurify';
import { z } from 'zod';
 
const commentSchema = z.object({
  content: z.string()
    .min(1)
    .max(1000)
    .transform(val => DOMPurify.sanitize(val)),
  postId: z.string().uuid(),
});
 
// Rate limiting middleware
import rateLimit from 'express-rate-limit';
 
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // Limit each IP to 100 requests per windowMs
  message: 'Too many requests from this IP',
  standardHeaders: true,
  legacyHeaders: false,
});
 
// CSRF protection
import csrf from 'csurf';
const csrfProtection = csrf({ cookie: true });
 
router.post('/api/sensitive-action', 
  authenticate,
  csrfProtection,
  apiLimiter,
  asyncHandler(async (req, res) => {
    // Handle sensitive action
  })
);

Common Pitfalls and Solutions

  1. Type Safety in API Responses
// ❌ Bad: Assuming API response shape
const data = await fetch('/api/users').then(r => r.json());
console.log(data.users[0].name); // Runtime error if shape differs
 
// ✅ Good: Validate response shape
import { z } from 'zod';
 
const usersResponseSchema = z.object({
  success: z.boolean(),
  data: z.array(userSchema),
});
 
const response = await fetch('/api/users');
const json = await response.json();
const validatedData = usersResponseSchema.parse(json);
  1. Memory Leaks in useEffect
// ❌ Bad: Not cleaning up subscriptions
useEffect(() => {
  const socket = io();
  socket.on('update', handleUpdate);
  // Missing cleanup!
}, []);
 
// ✅ Good: Proper cleanup
useEffect(() => {
  const socket = io();
  socket.on('update', handleUpdate);
  
  return () => {
    socket.off('update', handleUpdate);
    socket.disconnect();
  };
}, []);
  1. Race Conditions in Async Operations
// ✅ Good: Handle race conditions
function useSearch(query: string) {
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  
  useEffect(() => {
    let cancelled = false;
    
    async function search() {
      if (!query) {
        setResults([]);
        return;
      }
      
      setLoading(true);
      try {
        const data = await apiClient.get(`/search?q=${query}`);
        if (!cancelled) {
          setResults(data);
        }
      } catch (error) {
        if (!cancelled) {
          console.error('Search failed:', error);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    }
    
    search();
    
    return () => {
      cancelled = true;
    };
  }, [query]);
  
  return { results, loading };
}

Workshop Exercises

  1. Add Pagination Component: Create a reusable pagination component with proper TypeScript types
  2. Implement Caching: Add Redis caching to the API with proper type safety
  3. Build Filter System: Create advanced filtering with type-safe query builders
  4. Add File Upload: Implement file upload with progress tracking and type safety
  5. Create Admin Dashboard: Build role-based admin panel with proper authorization

Remember: In full-stack TypeScript, consistency between frontend and backend types is crucial. Use shared types, validate at boundaries, and leverage TypeScript’s full potential!