Introduction
When building MERN (MongoDB, Express.js, React, Node.js) applications with a team, proper project structure becomes critical for long-term success. A well-organized codebase reduces onboarding time, prevents bugs, enhances collaboration, and facilitates scaling more easily.
Why folder structure and conventions matter in large applications:
- Developer velocity: New team members can quickly understand and contribute to the codebase
- Maintainability: Clear separation of concerns makes debugging and refactoring easier
- Scalability: Organized structure supports adding new features without creating technical debt
- Code quality: Consistent patterns reduce the likelihood of bugs and anti-patterns
Common mistakes that hurt team productivity:
- Mixing frontend and backend logic in the same directories
- Creating deeply nested folder hierarchies that are hard to navigate
- Inconsistent naming conventions across the project
- Storing configuration and environment variables inconsistently
- Lack of shared utilities leading to code duplication
Let's dive into building a structure that avoids these pitfalls and scales with your team.
1. Basic MERN Folder Setup
Start with a clean separation between client, server, and shared code:
my-mern-app/
├── client/ # React frontend
├── server/ # Express backend
├── shared/ # Shared utilities, types, constants
├── .gitignore
├── package.json # Root package.json for scripts
└── README.md
Benefits of this structure:
- Clear boundaries: Frontend and backend teams can work independently
- Shared code reuse: Common utilities and types live in one place
- Deployment flexibility: Can deploy client and server separately
- Monorepo management: Easy to manage dependencies and scripts from the root
Root package.json example:
{
"name": "my-mern-app",
"private": true,
"scripts": {
"dev": "concurrently \"npm run server:dev\" \"npm run client:dev\"",
"server:dev": "cd server && npm run dev",
"client:dev": "cd client && npm start",
"install:all": "npm install && cd client && npm install && cd ../server && npm install"
},
"devDependencies": {
"concurrently": "^8.2.0"
}
}
2. Client-side Structure (React + State Management)
Organize your React application for scalability and maintainability:
client/
├── public/
│ ├── index.html
│ └── favicon.ico
├── src/
│ ├── components/ # Reusable UI components
│ │ ├── ui/ # Basic UI elements (Button, Input, Modal)
│ │ ├── forms/ # Form components
│ │ └── layout/ # Layout components (Header, Sidebar)
│ ├── pages/ # Page components
│ │ ├── auth/ # Authentication pages
│ │ ├── dashboard/ # Dashboard pages
│ │ └── settings/ # Settings pages
│ ├── hooks/ # Custom React hooks
│ ├── services/ # API calls and external services
│ │ ├── api.js # API client configuration
│ │ ├── auth.js # Authentication services
│ │ └── users.js # User-related API calls
│ ├── store/ # State management (RTK Query/Context)
│ │ ├── slices/ # Redux slices or context providers
│ │ └── index.js # Store configuration
│ ├── utils/ # Client-specific utilities
│ ├── styles/ # Global styles and themes
│ ├── App.js
│ └── index.js
├── package.json
└── .env.example
Key organization principles:
- Components by type, then by feature: Start with component types, group related features together
- Co-location: Keep related files close (component + styles + tests)
- Services layer: Separate API logic from component logic
- Custom hooks: Extract reusable stateful logic
Example service file structure:
// services/api.js
import axios from 'axios';
const api = axios.create({
baseURL: process.env.REACT_APP_API_URL,
});
// Add request/response interceptors for auth, error handling
export default api;
// services/users.js
import api from './api';
export const userService = {
getProfile: () => api.get('/api/users/profile'),
updateProfile: (data) => api.put('/api/users/profile', data),
getUsers: (params) => api.get('/api/users', { params }),
};
3. Server-side Structure (Express Routes, Controllers, Services)
Structure your backend following the separation of concerns principle:
server/
├── src/
│ ├── controllers/ # Request handlers
│ │ ├── auth.controller.js
│ │ ├── user.controller.js
│ │ └── index.js # Export all controllers
│ ├── services/ # Business logic layer
│ │ ├── auth.service.js
│ │ ├── user.service.js
│ │ └── email.service.js
│ ├── models/ # Database models
│ │ ├── User.js
│ │ ├── Post.js
│ │ └── index.js # Export all models
│ ├── routes/ # Route definitions
│ │ ├── auth.routes.js
│ │ ├── user.routes.js
│ │ └── index.js # Combine all routes
│ ├── middleware/ # Custom middleware
│ │ ├── auth.middleware.js
│ │ ├── error.middleware.js
│ │ └── validation.middleware.js
│ ├── config/ # Configuration files
│ │ ├── database.js
│ │ ├── passport.js
│ │ └── index.js
│ ├── utils/ # Server utilities
│ │ ├── logger.js
│ │ ├── validators.js
│ │ └── helpers.js
│ └── app.js # Express app setup
├── tests/ # Test files
├── package.json
└── .env.example
Controller-Service pattern example:
// controllers/user.controller.js
const userService = require('../services/user.service');
const getProfile = async (req, res, next) => {
try {
const user = await userService.getProfile(req.user.id);
res.json({ data: user });
} catch (error) {
next(error);
}
};
// services/user.service.js
const User = require('../models/User');
const getProfile = async (userId) => {
const user = await User.findById(userId).select('-password');
if (!user) {
throw new Error('User not found');
}
return user;
};
Benefits of this structure:
- Single responsibility: Each layer has a clear purpose
- Testability: Services can be tested independently
- Reusability: Services can be used by multiple controllers
- Maintainability: Changes to business logic stay in the service layer
4. Database Layer (MongoDB Models & Schema Organization)
Organize your MongoDB schemas and models for consistency and reusability:
server/src/models/
├── User.js
├── Post.js
├── Comment.js
├── schemas/ # Reusable schema definitions
│ ├── address.schema.js
│ ├── timestamp.schema.js
│ └── index.js
├── plugins/ # Mongoose plugins
│ ├── timestamps.plugin.js
│ └── softDelete.plugin.js
└── index.js # Export all models
Example schema organization:
// schemas/timestamp.schema.js
const timestampSchema = {
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
};
// plugins/timestamps.plugin.js
const timestampsPlugin = function(schema) {
schema.pre('save', function(next) {
this.updatedAt = Date.now();
next();
});
};
// models/User.js
const mongoose = require('mongoose');
const { timestampSchema } = require('./schemas');
const timestampsPlugin = require('./plugins/timestamps.plugin');
const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true },
name: { type: String, required: true },
role: { type: String, enum: ['user', 'admin'], default: 'user' },
...timestampSchema
});
userSchema.plugin(timestampsPlugin);
module.exports = mongoose.model('User', userSchema);
5. Environment Variables & Config Management
Create a robust configuration system that works across all environments:
# .env.example (root level)
NODE_ENV=development
# Database
MONGODB_URI=mongodb://localhost:27017/myapp
MONGODB_TEST_URI=mongodb://localhost:27017/myapp-test
# Server
PORT=5000
JWT_SECRET=your-super-secret-jwt-key
JWT_EXPIRES_IN=7d
# Client
REACT_APP_API_URL=http://localhost:5000
REACT_APP_ENVIRONMENT=development
# External Services
SENDGRID_API_KEY=your-sendgrid-key
AWS_ACCESS_KEY_ID=your-aws-access-key
AWS_SECRET_ACCESS_KEY=your-aws-secret-key
Server configuration management:
// server/src/config/index.js
require('dotenv').config();
const config = {
env: process.env.NODE_ENV || 'development',
port: process.env.PORT || 5000,
database: {
uri: process.env.NODE_ENV === 'test'
? process.env.MONGODB_TEST_URI
: process.env.MONGODB_URI,
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
},
email: {
apiKey: process.env.SENDGRID_API_KEY,
},
};
// Validate required environment variables
const requiredEnvVars = ['MONGODB_URI', 'JWT_SECRET'];
requiredEnvVars.forEach((envVar) => {
if (!process.env[envVar]) {
throw new Error(`Environment variable ${envVar} is required`);
}
});
module.exports = config;
6. Using the shared/
Folder for Types, Utils, and Constants
The shared folder prevents code duplication and ensures consistency:
shared/
├── types/ # TypeScript types/interfaces
│ ├── user.types.js # User-related types
│ ├── api.types.js # API response types
│ └── index.js
├── utils/ # Shared utilities
│ ├── validation.js # Common validation functions
│ ├── formatters.js # Data formatting utilities
│ └── constants.js # App-wide constants
├── schemas/ # Validation schemas (Joi, Yup, etc.)
│ ├── user.schema.js
│ └── auth.schema.js
└── package.json # Shared dependencies
Example shared utilities:
// shared/utils/validation.js
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const isValidEmail = (email) => emailRegex.test(email);
const isStrongPassword = (password) => {
// At least 8 characters, 1 uppercase, 1 lowercase, 1 number
const strongPasswordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,}$/;
return strongPasswordRegex.test(password);
};
module.exports = { isValidEmail, isStrongPassword };
// shared/utils/constants.js
const USER_ROLES = {
ADMIN: 'admin',
USER: 'user',
MODERATOR: 'moderator'
};
const API_ENDPOINTS = {
AUTH: {
LOGIN: '/api/auth/login',
REGISTER: '/api/auth/register',
LOGOUT: '/api/auth/logout'
},
USERS: {
PROFILE: '/api/users/profile',
LIST: '/api/users'
}
};
module.exports = { USER_ROLES, API_ENDPOINTS };
Using shared code in client and server:
// In client (React component)
import { isValidEmail, USER_ROLES } from '../../shared/utils';
// In server (Express controller)
const { isValidEmail, USER_ROLES } = require('../../../shared/utils');
7. Versioning & Git Workflow for Teams
Establish clear git practices and versioning strategies:
Branch naming conventions:
main # Production branch
develop # Development branch
feature/user-auth # Feature branches
bugfix/login-error # Bug fix branches
hotfix/security-patch # Hot fixes for production
Commit message format:
type(scope): description
feat(auth): add JWT token refresh mechanism
fix(user): resolve profile update validation error
docs(readme): update installation instructions
refactor(api): simplify error handling middleware
Package.json versioning scripts:
{
"scripts": {
"version:patch": "npm version patch --no-git-tag-version",
"version:minor": "npm version minor --no-git-tag-version",
"version:major": "npm version major --no-git-tag-version",
"release": "npm run build && npm run test && git add . && git commit -m 'chore: release' && npm version patch"
}
}
Pre-commit hooks with Husky:
{
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"pre-push": "npm run test"
}
},
"lint-staged": {
"*.{js,jsx}": ["eslint --fix", "prettier --write"],
"*.{json,css,md}": ["prettier --write"]
}
}
Environment-based deployment structure:
environments/
├── development/
│ ├── .env.development
│ └── docker-compose.yml
├── staging/
│ ├── .env.staging
│ └── docker-compose.yml
└── production/
├── .env.production
└── docker-compose.yml
Conclusion
A well-structured MERN project is the foundation for successful team collaboration and long-term maintainability. The structure we've outlined here provides:
- Clear separation of concerns between frontend, backend, and shared code
- Scalable organization that grows with your team and feature set
- Consistent patterns that reduce cognitive load and improve developer experience
- Robust configuration management across different environments
- Team-friendly workflows with clear git practices and automated quality checks
Remember, the best project structure is one that your team actually follows consistently. Begin with these patterns and adapt them according to your specific needs and team preferences.
What's your favorite MERN project structure? Have you found patterns that work particularly well for your team? Drop your preferred folder organization and any tips you've learned in the comments below. Let's learn from each other's experiences and build better apps together!
Top comments (0)