MERN Stack Architecture: Building Scalable Full-Stack Applications
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
# 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 organizationBackend: Express API with MongoDB
// 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
// 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
// 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
# 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 databaseSecurity 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