Project Architecture Overview
A modern full-stack application separates concerns across distinct layers: a React SPA (client), a Node.js + Express REST API (server), and a MongoDB database. Communication flows through HTTP/JSON for CRUD operations and WebSockets for real-time events.
Tech stack rationale: React's component model scales well as UI complexity grows. Express is minimal and composable — you wire only what you need. MongoDB's document model maps naturally to JavaScript objects, and the flexible schema suits iterative development. JWTs provide stateless, scalable authentication without server-side sessions.
Organise your project as a monorepo: /client (React), /server (Express), and a root docker-compose.yml. This keeps CI/CD simple and lets you share TypeScript types between layers via a /shared package.
Setting Up the Backend
Initialise the Node.js server with a clean, maintainable folder structure. Install Express, Mongoose, dotenv, and the essential middleware packages first.
mkdir server && cd server
npm init -y
npm install express mongoose dotenv cors helmet morgan bcryptjs jsonwebtoken express-validator
npm install -D nodemon typescript ts-node @types/node @types/express
Adopt a feature-based folder structure so each domain (users, posts, etc.) owns its routes, controller, service, and model:
server/
├── src/
│ ├── config/ # db.ts, env.ts
│ ├── middleware/ # auth.ts, errorHandler.ts, validate.ts
│ ├── modules/
│ │ ├── users/ # user.model.ts, user.routes.ts, user.controller.ts
│ │ └── posts/
│ ├── utils/ # logger.ts, ApiError.ts
│ └── app.ts # Express app factory
├── server.ts # Entry: connects DB, starts HTTP + Socket server
└── .env
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import { userRouter } from './modules/users/user.routes';
import { postRouter } from './modules/posts/post.routes';
import { errorHandler } from './middleware/errorHandler';
export function createApp() {
const app = express();
// Security & logging
app.use(helmet());
app.use(cors({ origin: process.env.CLIENT_URL, credentials: true }));
app.use(morgan('dev'));
app.use(express.json());
// Routes
app.use('/api/users', userRouter);
app.use('/api/posts', postRouter);
// Central error handler (must be last)
app.use(errorHandler);
return app;
}
import mongoose from 'mongoose';
export async function connectDB() {
const uri = process.env.MONGO_URI;
if (!uri) throw new Error('MONGO_URI not set');
await mongoose.connect(uri, {
serverSelectionTimeoutMS: 5000,
maxPoolSize: 10,
});
console.log('✅ MongoDB connected');
}
- Never commit
.env— add it to.gitignoreand use.env.examplefor documentation. - Use
helmet()to set secure HTTP headers with a single line. - Set
maxPoolSizeon the Mongoose connection to prevent connection exhaustion under load.
Building the REST API
Define a Mongoose schema, then wire CRUD controllers to Express routes. Follow the single-responsibility principle — routes declare paths, controllers call service functions, services hold business logic.
import { Schema, model, Document, Types } from 'mongoose';
export interface IPost extends Document {
title: string;
body: string;
author: Types.ObjectId;
tags: string[];
published: boolean;
createdAt: Date;
}
const PostSchema = new Schema({
title: { type: String, required: true, trim: true, maxlength: 200 },
body: { type: String, required: true },
author: { type: Schema.Types.ObjectId, ref: 'User', required: true },
tags: [{ type: String, lowercase: true, trim: true }],
published: { type: Boolean, default: false },
}, { timestamps: true });
PostSchema.index({ author: 1, createdAt: -1 });
PostSchema.index({ tags: 1 });
export const Post = model('Post', PostSchema);
import { Request, Response, NextFunction } from 'express';
import { Post } from './post.model';
import { ApiError } from '../../utils/ApiError';
export async function getPosts(req: Request, res: Response, next: NextFunction) {
try {
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(50, Number(req.query.limit) || 10);
const skip = (page - 1) * limit;
const [posts, total] = await Promise.all([
Post.find({ published: true })
.populate('author', 'name avatar')
.sort({ createdAt: -1 })
.skip(skip).limit(limit),
Post.countDocuments({ published: true }),
]);
res.json({ data: posts, meta: { page, limit, total, pages: Math.ceil(total / limit) } });
} catch (err) { next(err); }
}
export async function createPost(req: Request, res: Response, next: NextFunction) {
try {
const post = await Post.create({ ...req.body, author: req.user!._id });
res.status(201).json(post);
} catch (err) { next(err); }
}
export async function updatePost(req: Request, res: Response, next: NextFunction) {
try {
const post = await Post.findOneAndUpdate(
{ _id: req.params.id, author: req.user!._id },
req.body,
{ new: true, runValidators: true }
);
if (!post) throw new ApiError(404, 'Post not found');
res.json(post);
} catch (err) { next(err); }
}
import { Request, Response, NextFunction } from 'express';
import { ApiError } from '../utils/ApiError';
export function errorHandler(
err: Error, _req: Request, res: Response, _next: NextFunction
) {
if (err instanceof ApiError) {
return res.status(err.statusCode).json({ error: err.message });
}
// Mongoose validation error
if (err.name === 'ValidationError') {
return res.status(400).json({ error: err.message });
}
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
JWT Authentication
Implement a two-token strategy: a short-lived access token (15 min) sent in the Authorization header, and a long-lived refresh token (7 days) stored in an HttpOnly cookie to prevent XSS theft.
import { Schema, model, Document } from 'mongoose';
import bcrypt from 'bcryptjs';
export interface IUser extends Document {
name: string;
email: string;
passwordHash: string;
role: 'user' | 'admin';
refreshTokens: string[];
comparePassword(plain: string): Promise;
}
const UserSchema = new Schema({
name: { type: String, required: true, trim: true },
email: { type: String, required: true, unique: true, lowercase: true },
passwordHash: { type: String, required: true },
role: { type: String, enum: ['user','admin'], default: 'user' },
refreshTokens: [String],
}, { timestamps: true });
UserSchema.pre('save', async function () {
if (this.isModified('passwordHash'))
this.passwordHash = await bcrypt.hash(this.passwordHash, 12);
});
UserSchema.methods.comparePassword = function (plain: string) {
return bcrypt.compare(plain, this.passwordHash);
};
export const User = model('User', UserSchema);
import jwt from 'jsonwebtoken';
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!;
export const signAccess = (userId: string) =>
jwt.sign({ sub: userId }, ACCESS_SECRET, { expiresIn: '15m' });
export const signRefresh = (userId: string) =>
jwt.sign({ sub: userId }, REFRESH_SECRET, { expiresIn: '7d' });
export const verifyAccess = (token: string) =>
jwt.verify(token, ACCESS_SECRET) as jwt.JwtPayload;
export const verifyRefresh = (token: string) =>
jwt.verify(token, REFRESH_SECRET) as jwt.JwtPayload;
import { Request, Response, NextFunction } from 'express';
import { verifyAccess } from '../utils/tokens';
import { User } from '../modules/users/user.model';
import { ApiError } from '../utils/ApiError';
export async function requireAuth(req: Request, _res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) throw new ApiError(401, 'No token provided');
try {
const payload = verifyAccess(header.slice(7));
const user = await User.findById(payload.sub).select('-passwordHash -refreshTokens');
if (!user) throw new ApiError(401, 'User not found');
req.user = user;
next();
} catch {
next(new ApiError(401, 'Invalid or expired token'));
}
}
Store refresh tokens in the database and validate against the stored list on every refresh. This allows you to invalidate all sessions for a user (e.g., on password change or account compromise) by clearing their refreshTokens array.
Building the React Frontend
Bootstrap the frontend with Vite and React + TypeScript. Use React Router v6 for routing and React Context for lightweight global state (auth user, notifications). For complex server state, reach for TanStack Query.
npm create vite@latest client -- --template react-ts
cd client && npm install react-router-dom axios @tanstack/react-query zustand
import { createContext, useContext, useState, ReactNode } from 'react';
import type { User } from '../types';
interface AuthState {
user: User | null;
token: string | null;
login: (user: User, token: string) => void;
logout: () => void;
}
const AuthContext = createContext(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState(null);
const [token, setToken] = useState(
() => localStorage.getItem('cf_token')
);
const login = (u: User, t: string) => {
setUser(u);
setToken(t);
localStorage.setItem('cf_token', t);
};
const logout = () => {
setUser(null);
setToken(null);
localStorage.removeItem('cf_token');
};
return (
{children}
);
}
export const useAuth = () => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used inside AuthProvider');
return ctx;
};
- Separate page components (route-level) from feature components (business logic) and UI components (reusable, presentational).
- Co-locate a component's styles, tests, and types in a single folder:
PostCard/index.tsx,PostCard/PostCard.test.tsx. - Use TanStack Query for all server state — it handles caching, background refetch, and stale data automatically.
Connecting Frontend to Backend
Create a typed Axios instance that automatically attaches the access token and handles 401 responses by attempting a token refresh before retrying the original request.
import axios from 'axios';
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
withCredentials: true, // send HttpOnly refresh-token cookie
});
// Attach access token to every request
api.interceptors.request.use(config => {
const token = localStorage.getItem('cf_token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// Auto-refresh on 401
let refreshing = false;
let queue: Array<() => void> = [];
api.interceptors.response.use(
res => res,
async err => {
const original = err.config;
if (err.response?.status !== 401 || original._retry) return Promise.reject(err);
original._retry = true;
if (!refreshing) {
refreshing = true;
try {
const { data } = await axios.post('/api/auth/refresh', {}, { withCredentials: true });
localStorage.setItem('cf_token', data.accessToken);
queue.forEach(r => r());
queue = [];
} finally { refreshing = false; }
} else {
await new Promise(resolve => queue.push(resolve));
}
return api(original);
}
);
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../services/api';
import type { Post } from '../types';
export function usePosts(page = 1) {
return useQuery({
queryKey: ['posts', page],
queryFn: () => api.get(`/api/posts?page=${page}&limit=10`).then(r => r.data),
staleTime: 60_000,
});
}
export function useCreatePost() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: Partial) => api.post('/api/posts', body).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['posts'] }),
});
}
Real-time Features with WebSockets
Socket.io layered over your Express HTTP server enables bi-directional, event-driven communication — ideal for live notifications, collaborative cursors, and chat.
import http from 'http';
import { Server } from 'socket.io';
import { createApp } from './src/app';
import { connectDB } from './src/config/db';
import { verifyAccess } from './src/utils/tokens';
async function main() {
await connectDB();
const app = createApp();
const httpServer = http.createServer(app);
const io = new Server(httpServer, {
cors: { origin: process.env.CLIENT_URL, credentials: true },
});
// Authenticate socket connection via handshake token
io.use((socket, next) => {
try {
const token = socket.handshake.auth.token as string;
const payload = verifyAccess(token);
socket.data.userId = payload.sub;
next();
} catch { next(new Error('Unauthorized')); }
});
io.on('connection', socket => {
console.log(`Socket connected: ${socket.data.userId}`);
socket.join(`user:${socket.data.userId}`); // private room
socket.on('disconnect', () =>
console.log(`Socket disconnected: ${socket.data.userId}`)
);
});
// Emit notification from anywhere in the app:
// io.to(`user:${userId}`).emit('notification', { message });
httpServer.listen(process.env.PORT ?? 5000, () =>
console.log(`🚀 Server running on port ${process.env.PORT ?? 5000}`)
);
}
main();
import { useEffect, useRef } from 'react';
import { io, Socket } from 'socket.io-client';
import { useAuth } from '../context/AuthContext';
export function useSocket() {
const { token } = useAuth();
const socketRef = useRef(null);
useEffect(() => {
if (!token) return;
const socket = io(import.meta.env.VITE_API_URL, {
auth: { token },
});
socketRef.current = socket;
socket.on('notification', (data) => {
console.log('New notification:', data);
// dispatch to global notification store
});
return () => { socket.disconnect(); };
}, [token]);
return socketRef;
}
Deployment
Package the entire stack with Docker Compose for local parity and production deployment. Use multi-stage Docker builds to keep production images lean.
# ── Build stage ──────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ── Production stage ─────────────────────────────────────
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 5000
CMD ["node", "dist/server.js"]
version: '3.9'
services:
mongo:
image: mongo:7
restart: unless-stopped
volumes:
- mongo_data:/data/db
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD}
api:
build: ./server
restart: unless-stopped
depends_on: [mongo]
env_file: ./server/.env
ports:
- "5000:5000"
client:
build: ./client
restart: unless-stopped
ports:
- "3000:80"
nginx:
image: nginx:alpine
restart: unless-stopped
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
ports:
- "80:80"
- "443:443"
depends_on: [api, client]
volumes:
mongo_data:
name: Build & Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push API image
uses: docker/build-push-action@v5
with:
context: ./server
push: true
tags: myorg/api:${{ github.sha }},myorg/api:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_KEY }}
script: |
cd /opt/app
docker compose pull
docker compose up -d --remove-orphans
- Enable MongoDB authentication and restrict network access to internal Docker network only.
- Use
NGINXas a reverse proxy with TLS termination (Certbot/Let's Encrypt). - Set
NODE_ENV=production— Express disables stack traces in error responses automatically. - Pin Docker image digests in production to prevent unexpected updates.