How I Structure My Node.js Projects (2026)
A practical project structure that scales from side project to production.
The Problem
Most tutorials show you how to start a project.
Few show you how to organize it so it doesn't become a mess 3 months later.
Here's the structure I use for every Node.js project.
The Directory Structure
project-name/
├── src/
│ ├── config/ # Configuration files
│ │ ├── index.js # Config loader (env-based)
│ │ ├── database.js # DB connection
│ │ └── redis.js # Redis connection
│ │
│ ├── routes/ # Express route definitions
│ │ ├── index.js # Route registration
│ │ ├── users.js # User endpoints
│ │ └── health.js # Health check
│ │
│ ├── controllers/ # Route handlers (business logic)
│ │ ├── userController.js
│ │ └── healthController.js
│ │
│ ├── services/ # Business logic (no HTTP concerns)
│ │ ├── userService.js
│ │ └── emailService.js
│ │
│ ├── models/ # Data models / schemas
│ │ ├── User.js
│ │ └── index.js
│ │
│ ├── middleware/ # Express middleware
│ │ ├── auth.js # Authentication
│ │ ├── errorHandler.js
│ │ └── validate.js # Input validation
│ │
│ ├── utils/ # Pure utility functions
│ │ ├── logger.js
│ │ ├── response.js # Standardized API responses
│ │ └── helpers.js
│ │
│ └── app.js # App entry point (creates server)
│
├── tests/
│ ├── unit/
│ ├── integration/
│ └── fixtures/ # Test data
│
├── scripts/ # Maintenance / utility scripts
│ ├── seed.js # Database seeding
│ └── migrate.js # DB migrations
│
├── docs/ # API documentation
│
├── .env # Environment variables (gitignored)
├── .env.example # Template for contributors
├── .eslintrc.json
├── .gitignore
├── .prettierrc
├── Dockerfile
├── docker-compose.yml
├── package.json
├── server.js # Bootstraps app + starts listening
└── README.md
Key Principles
1. Separation of Concerns
// ❌ BAD — Everything in one file
app.get('/api/users/:id', async (req, res) => {
// Auth check
const token = req.headers.authorization;
if (!token) return res.status(401).json({ error: 'No token' });
// Validation
if (!req.params.id) return res.status(400).json({ error: 'Need ID' });
// Database query
const db = await sqlite.open('./data.db');
const user = await db.get('SELECT * FROM users WHERE id = ?', req.params.id);
// Response formatting
res.json({ success: true, data: user, timestamp: Date.now() });
});
// ✅ GOOD — Each layer has one job
// route: src/routes/users.js
router.get('/:id', authMiddleware, validateIdParam, userController.getById);
// controller: src/controllers/userController.js
const userService = require('../services/userService');
const ApiResponse = require('../utils/response');
exports.getById = async (req, res) => {
try {
const user = await userService.findById(req.params.id);
if (!user) return ApiResponse.notFound(res, 'User');
return ApiResponse.success(res, user);
} catch (err) {
next(err); // Let error handler deal with it
}
};
// service: src/services/userService.js
const User = require('../models/User');
exports.findById = async (id) => {
return User.findById(id);
};
2. Centralized Configuration
// src/config/index.js
require('dotenv').config();
module.exports = {
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT) || 3000,
database: {
path: process.env.DB_PATH || './data.db',
maxConnections: parseInt(process.env.DB_MAX_CONN) || 10,
},
redis: {
host: process.env.REDIS_HOST || '127.0.0.1',
port: parseInt(process.env.REDIS_PORT) || 6379,
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
},
rateLimit: {
windowMs: 60_000,
maxRequests: 100,
},
isProduction: () => module.exports.nodeEnv === 'production',
isTest: () => module.exports.nodeEnv === 'test',
};
3. Consistent Error Handling
// src/middleware/errorHandler.js
class AppError extends Error {
constructor(message, statusCode, code = 'ERROR') {
super(message);
this.statusCode = statusCode;
this.code = code;
this.isOperational = true; // Distinguishes from unexpected errors
}
}
// Predefined errors
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
class ValidationError extends AppError {
constructor(details = []) {
super('Validation failed', 422, 'VALIDATION_ERROR');
this.details = details;
}
}
class UnauthorizedError extends AppError {
constructor(message = 'Authentication required') {
super(message, 401, 'UNAUTHORIZED');
}
}
// The actual middleware
function errorHandler(err, _req, res, _next) {
console.error(`[ERROR] ${err.message}`, err.stack);
if (err.isOperational) {
return res.status(err.statusCode).json({
error: { code: err.code, message: err.message, details: err.details || [] }
});
}
// Unexpected errors — don't leak internals in production
const statusCode = err.statusCode || 500;
const message = process.env.NODE_ENV === 'production'
? 'An internal error occurred'
: err.message;
res.status(statusCode).json({
error: { code: 'INTERNAL_ERROR', message }
});
}
module.exports = { errorHandler, AppError, NotFoundError, ValidationError, UnauthorizedError };
4. Clean Service Layer
// src/services/userService.js
const { NotFoundError, ValidationError } = require('../middleware/errorHandler');
const User = require('../models/User');
exports.findById = async (id) => {
const user = await User.findById(id);
if (!user) throw new NotFoundError('User');
return user;
};
exports.create = async ({ email, name, password }) => {
// Validate
if (!email || !name || !password) {
throw new ValidationError([
...(email ? [] : [{ field: 'email', issue: 'Required' }]),
...(name ? [] : [{ field: 'name', issue: 'Required' }]),
...(password ? [] : [{ field: 'password', issue: 'Required' }])
]);
}
// Check duplicate
const existing = await User.findByEmail(email);
if (existing) {
throw new ValidationError([{ field: 'email', issue: 'Already registered' }]);
}
// Create
const user = await User.create({ email, name, password });
return user;
};
exports.update = async (id, updates) => {
const user = await exports.findById(id);
Object.assign(user, updates);
await user.save();
return user;
};
The Entry Point Pattern
// server.js — Minimal, clean
const app = require('./src/app');
const PORT = process.env.PORT || 3000;
if (require.main === module) {
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
});
}
module.exports = app; // Export for testing
// src/app.js — Wire everything together
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const compression = require('compression');
const routes = require('./routes');
const { errorHandler } = require('./middleware/errorHandler');
const { rateLimit } = require('./middleware/rateLimit');
const config = require('./config');
const app = express();
// Security & performance
app.use(helmet());
app.use(cors());
app.use(compression());
app.use(express.json({ limit: '1mb' }));
// Rate limiting
app.use(rateLimit(config.rateLimit.maxRequests, config.rateLimit.windowMs));
// Routes
app.use('/api', routes);
// Health check
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Error handler (MUST be last)
app.use(errorHandler);
// 404 handler
app.use((_req, res) => {
res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Endpoint not found' } });
});
module.exports = app;
package.json Scripts
{
"name": "my-project",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
"test": "node --test",
"test:watch": "node --test --watch",
"test:coverage": "node --experimental-test-coverage --test",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"format": "prettier --write 'src/**/*.js'",
"db:migrate": "node scripts/migrate.js",
"db:seed": "node scripts/seed.js",
"docker:build": "docker build -t my-project .",
"docker:up": "docker compose up -d"
},
"dependencies": {
"express": "^4.21.0",
"better-sqlite3": "^11.7.0",
"dotenv": "^16.4.5",
"helmet": "^8.0.0",
"cors": "^2.8.5"
},
"devDependencies": {
"eslint": "^9.14.0",
"prettier": "^3.4.0",
"supertest": "^7.0.0"
}
}
.gitignore Essentials
node_modules/
dist/
build/
.env
*.log
npm-debug.log*
coverage/
.nyc_output/
.DS_Store
data/*.db
data/*.db-journal
!.env.example
Testing Structure
tests/
├── unit/
│ ├── services/
│ │ └── userService.test.js
│ └── utils/
│ └── response.test.js
├── integration/
│ └── api/
│ └── users.test.js
└── fixtures/
└── users.json
// tests/integration/api/users.test.js
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import createApp from '../../../src/app.js';
describe('Users API', () => {
let app;
beforeEach(() => {
app = createApp();
});
it('GET /api/users/:id returns 404 for non-existent user', async () => {
const res = await request(app).get('/api/users/99999');
assert.equal(res.status, 404);
assert.equal(res.body.error.code, 'NOT_FOUND');
});
it('POST /api/users creates a user', async () => {
const res = await request(app)
.post('/api/users')
.send({ email: 'test@example.com', name: 'Test', password: 'SecurePass123!' })
.expect(201);
assert.ok(res.body.data.id);
assert.equal(res.body.data.email, 'test@example.com');
});
});
What About Smaller Projects?
For a single-file tool or simple script:
→ Don't over-structure. One file is fine.
For a small API (< 5 routes):
→ Skip the services layer. Put logic in controllers.
For a large application (> 20 routes):
→ Add layers as needed. This structure grows with you.
The key insight: Start simple, add structure when you feel pain.
What's your go-to project structure? Do you agree or disagree?
Follow @armorbreak for more Node.js guides.
Top comments (0)