All posts
Backend & APIs

MERN Stack Architecture: Building Scalable Full-Stack Applications

15 min readby imnb
MERNMongoDBExpressReactNode.jsFull Stack
Share

Build production-ready MERN applications with proper architecture, authentication, state management, API design, and deployment strategies. Complete guide from development to production.

The MERN stack (MongoDB, Express, React, Node.js) powers modern web applications. This guide covers production-grade architecture patterns, security, state management, and deployment for scalable full-stack apps.

Project Structure and Architecture

bash
# Production-ready MERN project structure
mern-app/
├── client/                    # React frontend
│   ├── public/
│   ├── src/
│   │   ├── components/        # Reusable UI components
│   │   ├── pages/             # Page components
│   │   ├── hooks/             # Custom hooks
│   │   ├── context/           # React context providers
│   │   ├── services/          # API calls
│   │   ├── utils/             # Helper functions
│   │   ├── App.jsx
│   │   └── main.jsx
│   ├── package.json
│   └── vite.config.js
├── server/                    # Express backend
│   ├── src/
│   │   ├── config/            # Configuration files
│   │   │   ├── db.js          # MongoDB connection
│   │   │   └── env.js         # Environment variables
│   │   ├── controllers/       # Route handlers
│   │   ├── models/            # Mongoose models
│   │   ├── routes/            # API routes
│   │   ├── middleware/        # Custom middleware
│   │   ├── utils/             # Helper functions
│   │   ├── validators/        # Input validation
│   │   └── server.js          # App entry point
│   ├── tests/
│   └── package.json
├── docker-compose.yml         # Development environment
└── README.md

# Why this structure?
# - Separation of concerns (client/server)
# - Easy to deploy separately (frontend CDN, backend servers)
# - Clear module boundaries
# - Testable code organization

Backend: Express API with MongoDB

javascript
// server/src/config/db.js
import mongoose from 'mongoose';

export const connectDB = async () => {
  try {
    const conn = await mongoose.connect(process.env.MONGODB_URI, {
      maxPoolSize: 50,
      wtimeoutMS: 2500,
      serverSelectionTimeoutMS: 5000
    });
    console.log(`MongoDB Connected: ${conn.connection.host}`);
  } catch (error) {
    console.error(`Error: ${error.message}`);
    process.exit(1);
  }
};

// server/src/models/User.js
import mongoose from 'mongoose';
import bcrypt from 'bcryptjs';

const userSchema = new mongoose.Schema({
  email: {
    type: String,
    required: true,
    unique: true,
    lowercase: true,
    trim: true,
    index: true
  },
  password: {
    type: String,
    required: true,
    minlength: 8,
    select: false  // Don't return password by default
  },
  name: {
    type: String,
    required: true,
    trim: true
  },
  role: {
    type: String,
    enum: ['user', 'admin'],
    default: 'user'
  },
  isVerified: {
    type: Boolean,
    default: false
  }
}, {
  timestamps: true  // createdAt, updatedAt
});

// Hash password before saving
userSchema.pre('save', async function(next) {
  if (!this.isModified('password')) return next();
  this.password = await bcrypt.hash(this.password, 12);
  next();
});

// Method to check password
userSchema.methods.comparePassword = async function(candidatePassword) {
  return bcrypt.compare(candidatePassword, this.password);
};

export default mongoose.model('User', userSchema);

// server/src/controllers/authController.js
import User from '../models/User.js';
import jwt from 'jsonwebtoken';

export const register = async (req, res) => {
  try {
    const { email, password, name } = req.body;
    
    // Check if user exists
    const existingUser = await User.findOne({ email });
    if (existingUser) {
      return res.status(400).json({ error: 'User already exists' });
    }
    
    // Create user
    const user = await User.create({ email, password, name });
    
    // Generate token
    const token = jwt.sign(
      { userId: user._id },
      process.env.JWT_SECRET,
      { expiresIn: '7d' }
    );
    
    res.status(201).json({
      token,
      user: {
        id: user._id,
        email: user.email,
        name: user.name
      }
    });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
};

// server/src/middleware/auth.js
export const authenticate = async (req, res, next) => {
  try {
    const token = req.headers.authorization?.replace('Bearer ', '');
    
    if (!token) {
      return res.status(401).json({ error: 'No token provided' });
    }
    
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    const user = await User.findById(decoded.userId);
    
    if (!user) {
      return res.status(401).json({ error: 'User not found' });
    }
    
    req.user = user;
    next();
  } catch (error) {
    res.status(401).json({ error: 'Invalid token' });
  }
};

Frontend: React with Context API

javascript
// client/src/context/AuthContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';
import { authService } from '../services/api';

const AuthContext = createContext(null);

export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Check if user is logged in on mount
    const token = localStorage.getItem('token');
    if (token) {
      authService.getProfile()
        .then(setUser)
        .catch(() => localStorage.removeItem('token'))
        .finally(() => setLoading(false));
    } else {
      setLoading(false);
    }
  }, []);

  const login = async (email, password) => {
    const { token, user } = await authService.login(email, password);
    localStorage.setItem('token', token);
    setUser(user);
  };

  const logout = () => {
    localStorage.removeItem('token');
    setUser(null);
  };

  return (
    <AuthContext.Provider value={{ user, loading, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
};

export const useAuth = () => {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
};

// client/src/services/api.js
import axios from 'axios';

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:5000/api'
});

// Add token to requests
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// Handle 401 errors
api.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      localStorage.removeItem('token');
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

export const authService = {
  login: async (email, password) => {
    const { data } = await api.post('/auth/login', { email, password });
    return data;
  },
  register: async (email, password, name) => {
    const { data } = await api.post('/auth/register', { email, password, name });
    return data;
  },
  getProfile: async () => {
    const { data } = await api.get('/auth/profile');
    return data;
  }
};

State Management Patterns

javascript
// Option 1: Context + useReducer for complex state
import { createContext, useReducer, useContext } from 'react';

const initialState = {
  posts: [],
  loading: false,
  error: null,
  currentPage: 1
};

function postsReducer(state, action) {
  switch (action.type) {
    case 'FETCH_START':
      return { ...state, loading: true, error: null };
    case 'FETCH_SUCCESS':
      return { ...state, loading: false, posts: action.payload };
    case 'FETCH_ERROR':
      return { ...state, loading: false, error: action.payload };
    case 'ADD_POST':
      return { ...state, posts: [action.payload, ...state.posts] };
    default:
      return state;
  }
}

export const PostsProvider = ({ children }) => {
  const [state, dispatch] = useReducer(postsReducer, initialState);
  
  const fetchPosts = async () => {
    dispatch({ type: 'FETCH_START' });
    try {
      const data = await api.get('/posts');
      dispatch({ type: 'FETCH_SUCCESS', payload: data });
    } catch (error) {
      dispatch({ type: 'FETCH_ERROR', payload: error.message });
    }
  };
  
  return (
    <PostsContext.Provider value={{ state, dispatch, fetchPosts }}>
      {children}
    </PostsContext.Provider>
  );
};

// Option 2: Zustand for simpler state management
import create from 'zustand';

export const usePostsStore = create((set, get) => ({
  posts: [],
  loading: false,
  error: null,
  
  fetchPosts: async () => {
    set({ loading: true, error: null });
    try {
      const data = await api.get('/posts');
      set({ posts: data, loading: false });
    } catch (error) {
      set({ error: error.message, loading: false });
    }
  },
  
  addPost: (post) => set((state) => ({
    posts: [post, ...state.posts]
  }))
}));

// Usage in component
function PostsList() {
  const { posts, loading, fetchPosts } = usePostsStore();
  
  useEffect(() => {
    fetchPosts();
  }, []);
  
  if (loading) return <Spinner />;
  return <div>{posts.map(post => <PostCard key={post.id} post={post} />)}</div>;
}

Production Deployment

yaml
# docker-compose.yml for development
version: '3.8'

services:
  mongodb:
    image: mongo:7
    ports:
      - "27017:27017"
    environment:
      MONGO_INITDB_ROOT_USERNAME: admin
      MONGO_INITDB_ROOT_PASSWORD: password
    volumes:
      - mongo-data:/data/db

  backend:
    build: ./server
    ports:
      - "5000:5000"
    environment:
      MONGODB_URI: mongodb://admin:password@mongodb:27017/myapp?authSource=admin
      JWT_SECRET: your-secret-key
      NODE_ENV: development
    depends_on:
      - mongodb
    volumes:
      - ./server:/app
      - /app/node_modules

  frontend:
    build: ./client
    ports:
      - "5173:5173"
    environment:
      VITE_API_URL: http://localhost:5000/api
    volumes:
      - ./client:/app
      - /app/node_modules

volumes:
  mongo-data:

# Production: Deploy frontend to Vercel/Netlify
# Deploy backend to AWS/Azure/Railway
# Use MongoDB Atlas for database

Security Best Practices

  • Use HTTPS in production (Let's Encrypt for free SSL)
  • Validate all inputs with libraries like joi or express-validator
  • Use parameterized queries to prevent NoSQL injection
  • Rate limit API endpoints to prevent abuse (express-rate-limit)
  • Implement CORS properly - don't use * in production
  • Store sensitive data in environment variables, never in code
  • Hash passwords with bcrypt (min 12 rounds)
  • Use JWT with short expiration times, refresh tokens for long sessions
  • Sanitize user input before rendering (prevent XSS)
  • Use helmet middleware for security headers
  • Enable MongoDB authentication in production
  • Keep dependencies updated (npm audit fix)
  • Implement proper error handling - don't leak stack traces
  • Use CSRF tokens for state-changing operations
  • Log security events for audit trails

Keep reading