Back to blog

Phase 4: Full-Stack Integration with TypeScript

typescriptfullstackmonorepodeploymentproduction
Phase 4: Full-Stack Integration with TypeScript

Welcome to Phase 4

You've mastered TypeScript fundamentals, built type-safe React applications, and created production-ready APIs. Now it's time to bring everything together.

In this final phase, you'll learn how to integrate frontend and backend into a cohesive full-stack application with shared types, monorepo architecture, end-to-end type safety, and production deployment strategies.

This is where TypeScript truly shines—types flow seamlessly from your database schema through your API to your frontend components, eliminating an entire class of integration bugs.

Time commitment: 3-4 weeks, 1-2 hours daily
Prerequisites: Phase 1, Phase 2, and Phase 3

What You'll Learn

By the end of Phase 4, you'll be able to:

✅ Structure full-stack projects with monorepo architecture
✅ Share TypeScript types between frontend and backend
✅ Integrate React with Express.js APIs using TanStack Query
✅ Implement end-to-end authentication (JWT + React Context)
✅ Build real-time features with WebSockets
✅ Handle file uploads from client to server
✅ Set up CI/CD pipelines with GitHub Actions
✅ Deploy to production (Docker, Railway, Vercel)
✅ Monitor and debug full-stack applications
✅ Implement API versioning and backwards compatibility

Monorepo Architecture with NPM Workspaces

Why Monorepos?

Monorepos let you manage multiple related packages in a single repository:

Benefits:

  • Shared code: Reuse types, utilities, and constants
  • Atomic changes: Update frontend and backend together
  • Simplified tooling: One configuration for linting, testing, deployment
  • Type safety: Import shared types without publishing to npm

Drawbacks:

  • Larger repository size
  • More complex build orchestration
  • Requires discipline with dependency management

Setting Up the Monorepo

mkdir fullstack-app
cd fullstack-app
npm init -y

Create package.json with workspaces:

{
  "name": "fullstack-app",
  "private": true,
  "workspaces": [
    "packages/*"
  ],
  "scripts": {
    "dev": "npm run dev --workspaces --if-present",
    "build": "npm run build --workspaces --if-present",
    "test": "npm run test --workspaces --if-present"
  },
  "devDependencies": {
    "typescript": "^5.7.3",
    "prettier": "^3.4.2",
    "eslint": "^9.18.0"
  }
}

Create Workspace Structure

mkdir -p packages/shared packages/backend packages/frontend
fullstack-app/
├── package.json              # Root workspace config
├── packages/
│   ├── shared/               # Shared types & utilities
│   │   ├── package.json
│   │   ├── tsconfig.json
│   │   └── src/
│   │       ├── types/        # TypeScript types
│   │       ├── utils/        # Common utilities
│   │       └── constants/    # Shared constants
│   ├── backend/              # Express.js API
│   │   ├── package.json
│   │   ├── tsconfig.json
│   │   └── src/
│   │       ├── routes/
│   │       ├── controllers/
│   │       └── services/
│   └── frontend/             # React app
│       ├── package.json
│       ├── tsconfig.json
│       └── src/
│           ├── components/
│           ├── pages/
│           └── hooks/

Building the Shared Package

1. Initialize Shared Package

packages/shared/package.json:

{
  "name": "@fullstack-app/shared",
  "version": "1.0.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch"
  },
  "devDependencies": {
    "typescript": "^5.7.3"
  }
}

packages/shared/tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2023",
    "module": "CommonJS",
    "declaration": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

2. Define Shared Types

packages/shared/src/types/user.ts:

export interface User {
  id: string;
  email: string;
  name: string;
  role: 'admin' | 'user';
  createdAt: Date;
  updatedAt: Date;
}
 
export interface CreateUserInput {
  email: string;
  password: string;
  name: string;
}
 
export interface LoginInput {
  email: string;
  password: string;
}
 
export interface AuthResponse {
  user: User;
  token: string;
}
 
export type UserWithoutPassword = Omit<User, 'password'>;

packages/shared/src/types/post.ts:

export interface Post {
  id: string;
  title: string;
  content: string;
  slug: string;
  published: boolean;
  authorId: string;
  createdAt: Date;
  updatedAt: Date;
}
 
export interface CreatePostInput {
  title: string;
  content: string;
  slug: string;
  published?: boolean;
}
 
export interface UpdatePostInput {
  title?: string;
  content?: string;
  published?: boolean;
}
 
export interface PostWithAuthor extends Post {
  author: {
    id: string;
    name: string;
    email: string;
  };
}

packages/shared/src/types/api.ts:

// Generic API response wrapper
export interface ApiResponse<T> {
  success: boolean;
  data?: T;
  error?: string;
  message?: string;
}
 
// Pagination
export interface PaginatedResponse<T> {
  items: T[];
  total: number;
  page: number;
  pageSize: number;
  totalPages: number;
}
 
export interface PaginationParams {
  page?: number;
  pageSize?: number;
  sortBy?: string;
  sortOrder?: 'asc' | 'desc';
}
 
// Validation error
export interface ValidationError {
  field: string;
  message: string;
}

packages/shared/src/utils/validation.ts:

export const isValidEmail = (email: string): boolean => {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(email);
};
 
export const isStrongPassword = (password: string): boolean => {
  // At least 8 chars, 1 uppercase, 1 lowercase, 1 number
  return (
    password.length >= 8 &&
    /[A-Z]/.test(password) &&
    /[a-z]/.test(password) &&
    /[0-9]/.test(password)
  );
};
 
export const slugify = (text: string): string => {
  return text
    .toLowerCase()
    .trim()
    .replace(/[^\w\s-]/g, '')
    .replace(/[\s_-]+/g, '-')
    .replace(/^-+|-+$/g, '');
};

packages/shared/src/constants/index.ts:

export const API_ROUTES = {
  AUTH: {
    LOGIN: '/api/auth/login',
    REGISTER: '/api/auth/register',
    LOGOUT: '/api/auth/logout',
    REFRESH: '/api/auth/refresh',
    ME: '/api/auth/me',
  },
  POSTS: {
    LIST: '/api/posts',
    CREATE: '/api/posts',
    GET: (id: string) => `/api/posts/${id}`,
    UPDATE: (id: string) => `/api/posts/${id}`,
    DELETE: (id: string) => `/api/posts/${id}`,
  },
  USERS: {
    LIST: '/api/users',
    GET: (id: string) => `/api/users/${id}`,
    UPDATE: (id: string) => `/api/users/${id}`,
    DELETE: (id: string) => `/api/users/${id}`,
  },
} as const;
 
export const HTTP_STATUS = {
  OK: 200,
  CREATED: 201,
  NO_CONTENT: 204,
  BAD_REQUEST: 400,
  UNAUTHORIZED: 401,
  FORBIDDEN: 403,
  NOT_FOUND: 404,
  CONFLICT: 409,
  UNPROCESSABLE_ENTITY: 422,
  INTERNAL_SERVER_ERROR: 500,
} as const;
 
export const ERROR_MESSAGES = {
  UNAUTHORIZED: 'You must be logged in',
  FORBIDDEN: 'You do not have permission',
  NOT_FOUND: 'Resource not found',
  VALIDATION_ERROR: 'Validation failed',
  SERVER_ERROR: 'Internal server error',
} as const;

packages/shared/src/index.ts:

// Export all types
export * from './types/user';
export * from './types/post';
export * from './types/api';
 
// Export utilities
export * from './utils/validation';
 
// Export constants
export * from './constants';

3. Build Shared Package

cd packages/shared
npm run build

This creates dist/ with compiled JavaScript and TypeScript declarations.

Integrating Backend with Shared Types

1. Install Shared Package in Backend

packages/backend/package.json:

{
  "name": "@fullstack-app/backend",
  "version": "1.0.0",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "@fullstack-app/shared": "*",
    "express": "^5.1.0",
    "cors": "^2.8.5",
    "dotenv": "^16.4.7",
    "jsonwebtoken": "^9.0.2",
    "bcryptjs": "^2.4.3",
    "@prisma/client": "^6.5.0",
    "zod": "^3.24.1"
  },
  "devDependencies": {
    "@types/express": "^5.0.0",
    "@types/cors": "^2.8.17",
    "@types/jsonwebtoken": "^9.0.7",
    "@types/bcryptjs": "^2.4.6",
    "tsx": "^4.20.0",
    "typescript": "^5.7.3",
    "prisma": "^6.5.0"
  }
}

2. Use Shared Types in Controllers

packages/backend/src/controllers/authController.ts:

import { Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { prisma } from '../lib/prisma';
import {
  LoginInput,
  CreateUserInput,
  AuthResponse,
  ApiResponse,
  HTTP_STATUS,
  ERROR_MESSAGES,
} from '@fullstack-app/shared';
 
export const register = async (
  req: Request<{}, {}, CreateUserInput>,
  res: Response<ApiResponse<AuthResponse>>
) => {
  try {
    const { email, password, name } = req.body;
 
    // Check if user exists
    const existingUser = await prisma.user.findUnique({ where: { email } });
    if (existingUser) {
      return res.status(HTTP_STATUS.CONFLICT).json({
        success: false,
        error: 'User already exists',
      });
    }
 
    // Hash password
    const hashedPassword = await bcrypt.hash(password, 10);
 
    // Create user
    const user = await prisma.user.create({
      data: {
        email,
        password: hashedPassword,
        name,
      },
    });
 
    // Generate token
    const token = jwt.sign(
      { userId: user.id, role: user.role },
      process.env.JWT_SECRET!,
      { expiresIn: '7d' }
    );
 
    res.status(HTTP_STATUS.CREATED).json({
      success: true,
      data: {
        user: {
          id: user.id,
          email: user.email,
          name: user.name,
          role: user.role,
          createdAt: user.createdAt,
          updatedAt: user.updatedAt,
        },
        token,
      },
    });
  } catch (error) {
    res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({
      success: false,
      error: ERROR_MESSAGES.SERVER_ERROR,
    });
  }
};
 
export const login = async (
  req: Request<{}, {}, LoginInput>,
  res: Response<ApiResponse<AuthResponse>>
) => {
  try {
    const { email, password } = req.body;
 
    // Find user
    const user = await prisma.user.findUnique({ where: { email } });
    if (!user) {
      return res.status(HTTP_STATUS.UNAUTHORIZED).json({
        success: false,
        error: 'Invalid credentials',
      });
    }
 
    // Check password
    const validPassword = await bcrypt.compare(password, user.password);
    if (!validPassword) {
      return res.status(HTTP_STATUS.UNAUTHORIZED).json({
        success: false,
        error: 'Invalid credentials',
      });
    }
 
    // Generate token
    const token = jwt.sign(
      { userId: user.id, role: user.role },
      process.env.JWT_SECRET!,
      { expiresIn: '7d' }
    );
 
    res.json({
      success: true,
      data: {
        user: {
          id: user.id,
          email: user.email,
          name: user.name,
          role: user.role,
          createdAt: user.createdAt,
          updatedAt: user.updatedAt,
        },
        token,
      },
    });
  } catch (error) {
    res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({
      success: false,
      error: ERROR_MESSAGES.SERVER_ERROR,
    });
  }
};

packages/backend/src/controllers/postController.ts:

import { Request, Response } from 'express';
import { prisma } from '../lib/prisma';
import {
  Post,
  CreatePostInput,
  UpdatePostInput,
  PostWithAuthor,
  ApiResponse,
  PaginatedResponse,
  PaginationParams,
  HTTP_STATUS,
  ERROR_MESSAGES,
} from '@fullstack-app/shared';
import { AuthRequest } from '../middleware/auth';
 
export const getPosts = async (
  req: Request<{}, {}, {}, PaginationParams>,
  res: Response<ApiResponse<PaginatedResponse<PostWithAuthor>>>
) => {
  try {
    const { page = 1, pageSize = 10, sortBy = 'createdAt', sortOrder = 'desc' } = req.query;
 
    const skip = (Number(page) - 1) * Number(pageSize);
    const take = Number(pageSize);
 
    const [posts, total] = await Promise.all([
      prisma.post.findMany({
        skip,
        take,
        orderBy: { [sortBy]: sortOrder },
        include: {
          author: {
            select: { id: true, name: true, email: true },
          },
        },
      }),
      prisma.post.count(),
    ]);
 
    res.json({
      success: true,
      data: {
        items: posts as PostWithAuthor[],
        total,
        page: Number(page),
        pageSize: Number(pageSize),
        totalPages: Math.ceil(total / Number(pageSize)),
      },
    });
  } catch (error) {
    res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({
      success: false,
      error: ERROR_MESSAGES.SERVER_ERROR,
    });
  }
};
 
export const createPost = async (
  req: AuthRequest<{}, {}, CreatePostInput>,
  res: Response<ApiResponse<Post>>
) => {
  try {
    const { title, content, slug, published = false } = req.body;
    const userId = req.user!.userId;
 
    const post = await prisma.post.create({
      data: {
        title,
        content,
        slug,
        published,
        authorId: userId,
      },
    });
 
    res.status(HTTP_STATUS.CREATED).json({
      success: true,
      data: post,
    });
  } catch (error) {
    res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({
      success: false,
      error: ERROR_MESSAGES.SERVER_ERROR,
    });
  }
};
 
export const updatePost = async (
  req: AuthRequest<{ id: string }, {}, UpdatePostInput>,
  res: Response<ApiResponse<Post>>
) => {
  try {
    const { id } = req.params;
    const userId = req.user!.userId;
    const updates = req.body;
 
    // Check if post exists and user owns it
    const existingPost = await prisma.post.findUnique({ where: { id } });
    if (!existingPost) {
      return res.status(HTTP_STATUS.NOT_FOUND).json({
        success: false,
        error: ERROR_MESSAGES.NOT_FOUND,
      });
    }
 
    if (existingPost.authorId !== userId && req.user!.role !== 'admin') {
      return res.status(HTTP_STATUS.FORBIDDEN).json({
        success: false,
        error: ERROR_MESSAGES.FORBIDDEN,
      });
    }
 
    const post = await prisma.post.update({
      where: { id },
      data: updates,
    });
 
    res.json({
      success: true,
      data: post,
    });
  } catch (error) {
    res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({
      success: false,
      error: ERROR_MESSAGES.SERVER_ERROR,
    });
  }
};

Integrating Frontend with Shared Types

1. Install Shared Package in Frontend

packages/frontend/package.json:

{
  "name": "@fullstack-app/frontend",
  "version": "1.0.0",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@fullstack-app/shared": "*",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "react-router-dom": "^7.1.3",
    "@tanstack/react-query": "^6.0.0",
    "axios": "^1.7.9",
    "zustand": "^5.0.3"
  },
  "devDependencies": {
    "@types/react": "^19.0.6",
    "@types/react-dom": "^19.0.2",
    "@vitejs/plugin-react": "^4.3.4",
    "typescript": "^5.7.3",
    "vite": "^6.0.7"
  }
}

2. Create Type-Safe API Client

packages/frontend/src/lib/api.ts:

import axios, { AxiosInstance } from 'axios';
import {
  ApiResponse,
  AuthResponse,
  LoginInput,
  CreateUserInput,
  User,
  Post,
  PostWithAuthor,
  CreatePostInput,
  UpdatePostInput,
  PaginatedResponse,
  PaginationParams,
  API_ROUTES,
} from '@fullstack-app/shared';
 
class ApiClient {
  private client: AxiosInstance;
 
  constructor(baseURL: string) {
    this.client = axios.create({
      baseURL,
      headers: {
        'Content-Type': 'application/json',
      },
    });
 
    // Add auth token to requests
    this.client.interceptors.request.use((config) => {
      const token = localStorage.getItem('token');
      if (token) {
        config.headers.Authorization = `Bearer ${token}`;
      }
      return config;
    });
  }
 
  // Auth endpoints
  async login(data: LoginInput): Promise<AuthResponse> {
    const response = await this.client.post<ApiResponse<AuthResponse>>(
      API_ROUTES.AUTH.LOGIN,
      data
    );
    return response.data.data!;
  }
 
  async register(data: CreateUserInput): Promise<AuthResponse> {
    const response = await this.client.post<ApiResponse<AuthResponse>>(
      API_ROUTES.AUTH.REGISTER,
      data
    );
    return response.data.data!;
  }
 
  async getMe(): Promise<User> {
    const response = await this.client.get<ApiResponse<User>>(
      API_ROUTES.AUTH.ME
    );
    return response.data.data!;
  }
 
  async logout(): Promise<void> {
    await this.client.post(API_ROUTES.AUTH.LOGOUT);
  }
 
  // Post endpoints
  async getPosts(params?: PaginationParams): Promise<PaginatedResponse<PostWithAuthor>> {
    const response = await this.client.get<ApiResponse<PaginatedResponse<PostWithAuthor>>>(
      API_ROUTES.POSTS.LIST,
      { params }
    );
    return response.data.data!;
  }
 
  async getPost(id: string): Promise<PostWithAuthor> {
    const response = await this.client.get<ApiResponse<PostWithAuthor>>(
      API_ROUTES.POSTS.GET(id)
    );
    return response.data.data!;
  }
 
  async createPost(data: CreatePostInput): Promise<Post> {
    const response = await this.client.post<ApiResponse<Post>>(
      API_ROUTES.POSTS.CREATE,
      data
    );
    return response.data.data!;
  }
 
  async updatePost(id: string, data: UpdatePostInput): Promise<Post> {
    const response = await this.client.put<ApiResponse<Post>>(
      API_ROUTES.POSTS.UPDATE(id),
      data
    );
    return response.data.data!;
  }
 
  async deletePost(id: string): Promise<void> {
    await this.client.delete(API_ROUTES.POSTS.DELETE(id));
  }
}
 
export const api = new ApiClient(import.meta.env.VITE_API_URL || 'http://localhost:4000');

3. Create Auth Store with Zustand

packages/frontend/src/stores/authStore.ts:

import { create } from 'zustand';
import { User } from '@fullstack-app/shared';
import { api } from '../lib/api';
 
interface AuthState {
  user: User | null;
  token: string | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  login: (email: string, password: string) => Promise<void>;
  register: (email: string, password: string, name: string) => Promise<void>;
  logout: () => void;
  fetchUser: () => Promise<void>;
}
 
export const useAuthStore = create<AuthState>((set) => ({
  user: null,
  token: localStorage.getItem('token'),
  isAuthenticated: !!localStorage.getItem('token'),
  isLoading: false,
 
  login: async (email, password) => {
    set({ isLoading: true });
    try {
      const { user, token } = await api.login({ email, password });
      localStorage.setItem('token', token);
      set({ user, token, isAuthenticated: true, isLoading: false });
    } catch (error) {
      set({ isLoading: false });
      throw error;
    }
  },
 
  register: async (email, password, name) => {
    set({ isLoading: true });
    try {
      const { user, token } = await api.register({ email, password, name });
      localStorage.setItem('token', token);
      set({ user, token, isAuthenticated: true, isLoading: false });
    } catch (error) {
      set({ isLoading: false });
      throw error;
    }
  },
 
  logout: () => {
    localStorage.removeItem('token');
    set({ user: null, token: null, isAuthenticated: false });
    api.logout().catch(() => {});
  },
 
  fetchUser: async () => {
    try {
      const user = await api.getMe();
      set({ user });
    } catch (error) {
      // Token invalid, clear auth
      localStorage.removeItem('token');
      set({ user: null, token: null, isAuthenticated: false });
    }
  },
}));

4. Create React Components with Shared Types

packages/frontend/src/components/LoginForm.tsx:

import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../stores/authStore';
import { isValidEmail } from '@fullstack-app/shared';
 
export function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const { login, isLoading } = useAuthStore();
  const navigate = useNavigate();
 
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
 
    if (!isValidEmail(email)) {
      setError('Invalid email address');
      return;
    }
 
    try {
      await login(email, password);
      navigate('/dashboard');
    } catch (err) {
      setError('Invalid credentials');
    }
  };
 
  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <label htmlFor="email" className="block text-sm font-medium">
          Email
        </label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          className="mt-1 block w-full rounded border p-2"
          required
        />
      </div>
 
      <div>
        <label htmlFor="password" className="block text-sm font-medium">
          Password
        </label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          className="mt-1 block w-full rounded border p-2"
          required
        />
      </div>
 
      {error && <p className="text-red-500 text-sm">{error}</p>}
 
      <button
        type="submit"
        disabled={isLoading}
        className="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600 disabled:opacity-50"
      >
        {isLoading ? 'Logging in...' : 'Login'}
      </button>
    </form>
  );
}

packages/frontend/src/components/PostList.tsx:

import { useQuery } from '@tanstack/react-query';
import { api } from '../lib/api';
import { PostWithAuthor } from '@fullstack-app/shared';
 
export function PostList() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['posts'],
    queryFn: () => api.getPosts({ page: 1, pageSize: 10 }),
  });
 
  if (isLoading) return <div>Loading posts...</div>;
  if (error) return <div>Error loading posts</div>;
  if (!data) return null;
 
  return (
    <div className="space-y-4">
      <h2 className="text-2xl font-bold">Posts ({data.total})</h2>
      
      <div className="grid gap-4">
        {data.items.map((post: PostWithAuthor) => (
          <article key={post.id} className="border rounded p-4">
            <h3 className="text-xl font-semibold">{post.title}</h3>
            <p className="text-gray-600 mt-2">{post.content.slice(0, 150)}...</p>
            <div className="mt-2 text-sm text-gray-500">
              By {post.author.name} • {new Date(post.createdAt).toLocaleDateString()}
            </div>
          </article>
        ))}
      </div>
 
      <div className="flex justify-between items-center">
        <p className="text-sm text-gray-600">
          Page {data.page} of {data.totalPages}
        </p>
      </div>
    </div>
  );
}

End-to-End Authentication Flow

Backend: JWT Middleware

packages/backend/src/middleware/auth.ts:

import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { HTTP_STATUS, ERROR_MESSAGES } from '@fullstack-app/shared';
 
export interface AuthRequest<P = any, ResBody = any, ReqBody = any> 
  extends Request<P, ResBody, ReqBody> {
  user?: {
    userId: string;
    role: 'admin' | 'user';
  };
}
 
export const authenticate = (
  req: AuthRequest,
  res: Response,
  next: NextFunction
) => {
  try {
    const token = req.headers.authorization?.replace('Bearer ', '');
    
    if (!token) {
      return res.status(HTTP_STATUS.UNAUTHORIZED).json({
        success: false,
        error: ERROR_MESSAGES.UNAUTHORIZED,
      });
    }
 
    const decoded = jwt.verify(token, process.env.JWT_SECRET!) as {
      userId: string;
      role: 'admin' | 'user';
    };
 
    req.user = decoded;
    next();
  } catch (error) {
    res.status(HTTP_STATUS.UNAUTHORIZED).json({
      success: false,
      error: 'Invalid token',
    });
  }
};
 
export const requireAdmin = (
  req: AuthRequest,
  res: Response,
  next: NextFunction
) => {
  if (req.user?.role !== 'admin') {
    return res.status(HTTP_STATUS.FORBIDDEN).json({
      success: false,
      error: ERROR_MESSAGES.FORBIDDEN,
    });
  }
  next();
};

Frontend: Protected Routes

packages/frontend/src/components/ProtectedRoute.tsx:

import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../stores/authStore';
 
interface ProtectedRouteProps {
  children: React.ReactNode;
}
 
export function ProtectedRoute({ children }: ProtectedRouteProps) {
  const { isAuthenticated } = useAuthStore();
 
  if (!isAuthenticated) {
    return <Navigate to="/login" replace />;
  }
 
  return <>{children}</>;
}

packages/frontend/src/App.tsx:

import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { LoginForm } from './components/LoginForm';
import { PostList } from './components/PostList';
import { ProtectedRoute } from './components/ProtectedRoute';
 
const queryClient = new QueryClient();
 
export function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <BrowserRouter>
        <Routes>
          <Route path="/login" element={<LoginForm />} />
          <Route
            path="/dashboard"
            element={
              <ProtectedRoute>
                <PostList />
              </ProtectedRoute>
            }
          />
        </Routes>
      </BrowserRouter>
    </QueryClientProvider>
  );
}

Real-Time Features with WebSockets

Backend: Socket.io Setup

cd packages/backend
npm install socket.io
npm install --save-dev @types/socket.io

packages/backend/src/socket/index.ts:

import { Server } from 'socket.io';
import { Server as HttpServer } from 'http';
import jwt from 'jsonwebtoken';
 
export function setupWebSocket(httpServer: HttpServer) {
  const io = new Server(httpServer, {
    cors: {
      origin: process.env.FRONTEND_URL || 'http://localhost:5173',
      credentials: true,
    },
  });
 
  // Authentication middleware
  io.use((socket, next) => {
    const token = socket.handshake.auth.token;
    
    if (!token) {
      return next(new Error('Authentication error'));
    }
 
    try {
      const decoded = jwt.verify(token, process.env.JWT_SECRET!);
      socket.data.user = decoded;
      next();
    } catch (err) {
      next(new Error('Invalid token'));
    }
  });
 
  io.on('connection', (socket) => {
    console.log('User connected:', socket.data.user.userId);
 
    socket.on('join-room', (roomId: string) => {
      socket.join(roomId);
      console.log(`User ${socket.data.user.userId} joined room ${roomId}`);
    });
 
    socket.on('send-message', (data: { roomId: string; message: string }) => {
      io.to(data.roomId).emit('new-message', {
        userId: socket.data.user.userId,
        message: data.message,
        timestamp: new Date(),
      });
    });
 
    socket.on('disconnect', () => {
      console.log('User disconnected:', socket.data.user.userId);
    });
  });
 
  return io;
}

packages/backend/src/index.ts:

import express from 'express';
import http from 'http';
import cors from 'cors';
import { setupWebSocket } from './socket';
 
const app = express();
const httpServer = http.createServer(app);
 
app.use(cors());
app.use(express.json());
 
// Setup WebSocket
setupWebSocket(httpServer);
 
// Routes...
 
const PORT = process.env.PORT || 4000;
httpServer.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Frontend: Socket.io Client

cd packages/frontend
npm install socket.io-client

packages/frontend/src/hooks/useWebSocket.ts:

import { useEffect, useState } from 'react';
import { io, Socket } from 'socket.io-client';
import { useAuthStore } from '../stores/authStore';
 
export function useWebSocket() {
  const [socket, setSocket] = useState<Socket | null>(null);
  const { token } = useAuthStore();
 
  useEffect(() => {
    if (!token) return;
 
    const newSocket = io(import.meta.env.VITE_API_URL || 'http://localhost:4000', {
      auth: { token },
    });
 
    newSocket.on('connect', () => {
      console.log('WebSocket connected');
    });
 
    newSocket.on('disconnect', () => {
      console.log('WebSocket disconnected');
    });
 
    setSocket(newSocket);
 
    return () => {
      newSocket.close();
    };
  }, [token]);
 
  return socket;
}

File Upload Implementation

Backend: Multer Setup

cd packages/backend
npm install multer
npm install --save-dev @types/multer

packages/backend/src/middleware/upload.ts:

import multer from 'multer';
import path from 'path';
 
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, 'uploads/');
  },
  filename: (req, file, cb) => {
    const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
    cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
  },
});
 
const fileFilter = (req: any, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
  const allowedTypes = /jpeg|jpg|png|gif|pdf/;
  const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
  const mimetype = allowedTypes.test(file.mimetype);
 
  if (extname && mimetype) {
    cb(null, true);
  } else {
    cb(new Error('Invalid file type'));
  }
};
 
export const upload = multer({
  storage,
  fileFilter,
  limits: {
    fileSize: 5 * 1024 * 1024, // 5MB limit
  },
});

packages/backend/src/routes/upload.ts:

import express from 'express';
import { upload } from '../middleware/upload';
import { authenticate } from '../middleware/auth';
 
const router = express.Router();
 
router.post('/upload', authenticate, upload.single('file'), (req, res) => {
  if (!req.file) {
    return res.status(400).json({
      success: false,
      error: 'No file uploaded',
    });
  }
 
  res.json({
    success: true,
    data: {
      filename: req.file.filename,
      path: `/uploads/${req.file.filename}`,
      size: req.file.size,
    },
  });
});
 
export default router;

Frontend: File Upload Component

packages/frontend/src/components/FileUpload.tsx:

import { useState } from 'react';
import axios from 'axios';
 
export function FileUpload() {
  const [file, setFile] = useState<File | null>(null);
  const [uploading, setUploading] = useState(false);
  const [uploadedUrl, setUploadedUrl] = useState('');
 
  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files && e.target.files[0]) {
      setFile(e.target.files[0]);
    }
  };
 
  const handleUpload = async () => {
    if (!file) return;
 
    const formData = new FormData();
    formData.append('file', file);
 
    setUploading(true);
    try {
      const token = localStorage.getItem('token');
      const response = await axios.post(
        `${import.meta.env.VITE_API_URL}/api/upload`,
        formData,
        {
          headers: {
            'Content-Type': 'multipart/form-data',
            Authorization: `Bearer ${token}`,
          },
        }
      );
 
      setUploadedUrl(response.data.data.path);
      alert('File uploaded successfully!');
    } catch (error) {
      alert('Upload failed');
    } finally {
      setUploading(false);
    }
  };
 
  return (
    <div className="space-y-4">
      <input type="file" onChange={handleFileChange} />
      
      <button
        onClick={handleUpload}
        disabled={!file || uploading}
        className="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
      >
        {uploading ? 'Uploading...' : 'Upload File'}
      </button>
 
      {uploadedUrl && (
        <p className="text-sm text-green-600">
          File uploaded: {uploadedUrl}
        </p>
      )}
    </div>
  );
}

Deployment Strategies

Docker Deployment

Root Dockerfile (multi-stage):

# Stage 1: Build shared package
FROM node:22-alpine AS shared-builder
WORKDIR /app
COPY package*.json ./
COPY packages/shared/package*.json ./packages/shared/
RUN npm install --workspace=@fullstack-app/shared
COPY packages/shared ./packages/shared
RUN npm run build --workspace=@fullstack-app/shared
 
# Stage 2: Build backend
FROM node:22-alpine AS backend-builder
WORKDIR /app
COPY package*.json ./
COPY packages/backend/package*.json ./packages/backend/
COPY --from=shared-builder /app/packages/shared ./packages/shared
RUN npm install --workspace=@fullstack-app/backend
COPY packages/backend ./packages/backend
RUN npm run build --workspace=@fullstack-app/backend
 
# Stage 3: Build frontend
FROM node:22-alpine AS frontend-builder
WORKDIR /app
COPY package*.json ./
COPY packages/frontend/package*.json ./packages/frontend/
COPY --from=shared-builder /app/packages/shared ./packages/shared
RUN npm install --workspace=@fullstack-app/frontend
COPY packages/frontend ./packages/frontend
RUN npm run build --workspace=@fullstack-app/frontend
 
# Stage 4: Production backend
FROM node:22-alpine AS backend-production
WORKDIR /app
COPY --from=backend-builder /app/packages/backend/dist ./dist
COPY --from=backend-builder /app/packages/backend/package*.json ./
COPY --from=shared-builder /app/packages/shared/dist ./node_modules/@fullstack-app/shared/dist
RUN npm install --production
EXPOSE 4000
CMD ["node", "dist/index.js"]
 
# Stage 5: Production frontend (Nginx)
FROM nginx:alpine AS frontend-production
COPY --from=frontend-builder /app/packages/frontend/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

docker-compose.yml:

version: '3.8'
 
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: fullstack
      POSTGRES_PASSWORD: password
      POSTGRES_DB: fullstack_db
    volumes:
      - postgres-data:/var/lib/postgresql/data
    ports:
      - "5432:5432"
 
  backend:
    build:
      context: .
      target: backend-production
    environment:
      DATABASE_URL: postgresql://fullstack:password@postgres:5432/fullstack_db
      JWT_SECRET: your-secret-key
      NODE_ENV: production
    depends_on:
      - postgres
    ports:
      - "4000:4000"
 
  frontend:
    build:
      context: .
      target: frontend-production
    environment:
      VITE_API_URL: http://localhost:4000
    ports:
      - "80:80"
    depends_on:
      - backend
 
volumes:
  postgres-data:

Deploy with Railway

Railway supports monorepos out of the box:

  1. Connect GitHub repository
  2. Create services for backend and frontend
  3. Configure build commands:
    • Backend: npm install && npm run build --workspace=@fullstack-app/backend
    • Frontend: npm install && npm run build --workspace=@fullstack-app/frontend
  4. Set environment variables per service
  5. Railway auto-detects ports and handles DNS

Deploy Frontend to Vercel

vercel.json:

{
  "buildCommand": "npm install && npm run build --workspace=@fullstack-app/frontend",
  "outputDirectory": "packages/frontend/dist",
  "framework": "vite"
}

CI/CD with GitHub Actions

.github/workflows/ci.yml:

name: CI/CD Pipeline
 
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
 
jobs:
  test-shared:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
      - run: npm ci
      - run: npm run build --workspace=@fullstack-app/shared
      - run: npm run test --workspace=@fullstack-app/shared
 
  test-backend:
    runs-on: ubuntu-latest
    needs: test-shared
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: password
          POSTGRES_DB: test_db
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
      - run: npm ci
      - run: npm run build --workspace=@fullstack-app/shared
      - run: npm run build --workspace=@fullstack-app/backend
      - run: npm run test --workspace=@fullstack-app/backend
        env:
          DATABASE_URL: postgresql://postgres:password@localhost:5432/test_db
 
  test-frontend:
    runs-on: ubuntu-latest
    needs: test-shared
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
      - run: npm ci
      - run: npm run build --workspace=@fullstack-app/shared
      - run: npm run build --workspace=@fullstack-app/frontend
      - run: npm run test --workspace=@fullstack-app/frontend
 
  deploy:
    runs-on: ubuntu-latest
    needs: [test-backend, test-frontend]
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to Railway
        run: |
          curl -X POST ${{ secrets.RAILWAY_WEBHOOK_URL }}

Monitoring and Debugging

Backend: Structured Logging

npm install winston --workspace=@fullstack-app/backend

packages/backend/src/lib/logger.ts:

import winston from 'winston';
 
export const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  transports: [
    new winston.transports.Console({
      format: winston.format.combine(
        winston.format.colorize(),
        winston.format.simple()
      ),
    }),
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' }),
  ],
});

Frontend: Error Boundary

packages/frontend/src/components/ErrorBoundary.tsx:

import { Component, ErrorInfo, ReactNode } from 'react';
 
interface Props {
  children: ReactNode;
  fallback?: ReactNode;
}
 
interface State {
  hasError: boolean;
  error?: Error;
}
 
export class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false };
  }
 
  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }
 
  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error('Error caught by boundary:', error, errorInfo);
    // Send to error tracking service (Sentry, etc.)
  }
 
  render() {
    if (this.state.hasError) {
      return this.props.fallback || (
        <div className="p-4 bg-red-50 border border-red-200 rounded">
          <h2 className="text-lg font-semibold text-red-800">
            Something went wrong
          </h2>
          <p className="text-sm text-red-600 mt-2">
            {this.state.error?.message}
          </p>
        </div>
      );
    }
 
    return this.props.children;
  }
}

Best Practices Summary

Project Structure

✅ Use monorepo for full-stack type sharing
✅ Keep shared package dependency-free (types only)
✅ Version shared package with semantic versioning
✅ Document breaking changes in shared types

Type Safety

✅ Use strict TypeScript configuration
✅ Define API contracts in shared package
✅ Type API responses and error states
✅ Use discriminated unions for API responses

API Integration

✅ Create centralized API client
✅ Use React Query for server state
✅ Implement optimistic updates
✅ Handle loading and error states gracefully

Authentication

✅ Store JWT in httpOnly cookies (best) or localStorage
✅ Implement token refresh mechanism
✅ Clear auth state on 401 responses
✅ Protect routes on both frontend and backend

Testing

✅ Write integration tests for API endpoints
✅ Test components with mocked API responses
✅ Test shared utilities independently
✅ Automate tests in CI pipeline

Deployment

✅ Use environment variables for configuration
✅ Implement health check endpoints
✅ Set up logging and monitoring
✅ Use Docker for consistent deployments
✅ Implement blue-green or rolling deployments

Common Pitfalls to Avoid

Circular dependencies between packages
✅ Keep shared package as "dumb" types only

Forgetting to rebuild shared package
✅ Use npm run dev in watch mode or pre-build scripts

Coupling frontend directly to database schemas
✅ Create separate API types and transform server-side

Not handling API errors
✅ Use try-catch and display user-friendly messages

Storing secrets in frontend code
✅ Use environment variables, never commit .env files

Not versioning API endpoints
✅ Use /api/v1/ prefixes for backwards compatibility

Next Steps

Congratulations! You've completed the TypeScript Full-Stack Roadmap. You now know how to:

  • Build type-safe React frontends
  • Create robust Node.js backends
  • Share types across the stack
  • Integrate frontend and backend seamlessly
  • Deploy full-stack applications to production

Continue Learning

Advanced topics to explore:

  • Microservices architecture with TypeScript
  • GraphQL with TypeScript code generation
  • Server-Side Rendering (SSR) with Next.js
  • Real-time collaboration features
  • Advanced deployment strategies (Kubernetes, AWS)
  • Performance optimization and caching strategies

Other roadmaps:

Resources


Deep Dives

Ready to master specific areas? Check out the deep dive posts:

📘 Deep Dive: Advanced TypeScript Types → — Conditional types, mapped types, branded types 📘 Deep Dive: React with TypeScript → — Component patterns, hooks typing, performance 📘 Deep Dive: Node.js API Development → — Production API patterns 📘 Deep Dive: Database & ORMs → — Prisma, Drizzle, testing 📘 Deep Dive: Testing & DevOps → — Vitest, Playwright, CI/CD


Previous: Phase 3: Backend Development Series: TypeScript Full-Stack Roadmap

Happy coding! 🚀

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.