MERN Stack Architecture: Building Scalable Full-Stack Applications
Build maintainable MERN applications with clear boundaries for authentication, state management, API design, and deployment.
The MERN stack is easy to start and easy to blur: UI state leaks into API code, authentication becomes middleware scattered across routes, and a convenient MongoDB document turns into an accidental schema. A durable MERN application keeps the client, API, data model, and deployment contract separate enough to change independently.
Project Structure and Architecture
# 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