Implementation Guide

Building a Full-Stack Web Application

A complete end-to-end walkthrough of architecting, building, and deploying a production-grade full-stack application using React, Node.js, MongoDB, and JWT authentication.

45 min read
Advanced
Updated 2025
React Node.js MongoDB JWT Auth REST API
1

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.

React SPA
Port 3000
Express API
Port 5000
MongoDB
Port 27017
Axios / Fetch
JWT Middleware
Mongoose ODM
Socket.io Client
Socket.io Server

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.

Monorepo Structure

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.

2

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.

bashterminal
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:

textserver/ folder structure
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
typescriptsrc/app.ts
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;
}
typescriptsrc/config/db.ts
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');
}
Best Practices
  • Never commit .env — add it to .gitignore and use .env.example for documentation.
  • Use helmet() to set secure HTTP headers with a single line.
  • Set maxPoolSize on the Mongoose connection to prevent connection exhaustion under load.
3

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.

typescriptmodules/posts/post.model.ts
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);
typescriptmodules/posts/post.controller.ts
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); }
}
typescriptmiddleware/errorHandler.ts
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' });
}
4

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.

typescriptmodules/users/user.model.ts
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);
typescriptutils/tokens.ts
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;
typescriptmiddleware/auth.ts
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'));
  }
}
Security Warning

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.

5

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.

bashterminal
npm create vite@latest client -- --template react-ts
cd client && npm install react-router-dom axios @tanstack/react-query zustand
tsxcontext/AuthContext.tsx
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;
};
Component Architecture Tips
  • 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.
6

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.

typescriptservices/api.ts
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);
  }
);
tsxhooks/usePosts.ts
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'] }),
  });
}
7

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.

typescriptserver.ts
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();
tsxhooks/useSocket.ts
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;
}
8

Deployment

Package the entire stack with Docker Compose for local parity and production deployment. Use multi-stage Docker builds to keep production images lean.

dockerfileserver/Dockerfile
# ── 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"]
yamldocker-compose.yml
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:
yaml.github/workflows/deploy.yml
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
Production Checklist
  • Enable MongoDB authentication and restrict network access to internal Docker network only.
  • Use NGINX as 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.