DEV Community

Cover image for How to Structure a Scalable MERN Project for Teams
Rayan Hossain
Rayan Hossain

Posted on

How to Structure a Scalable MERN Project for Teams

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }),
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"]
  }
}
Enter fullscreen mode Exit fullscreen mode

Environment-based deployment structure:

environments/
├── development/
│   ├── .env.development
│   └── docker-compose.yml
├── staging/
│   ├── .env.staging
│   └── docker-compose.yml
└── production/
    ├── .env.production
    └── docker-compose.yml
Enter fullscreen mode Exit fullscreen mode

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)