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
- 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);- 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();
};
}, []);- 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
- Add Pagination Component: Create a reusable pagination component with proper TypeScript types
- Implement Caching: Add Redis caching to the API with proper type safety
- Build Filter System: Create advanced filtering with type-safe query builders
- Add File Upload: Implement file upload with progress tracking and type safety
- 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!