DEV Community

Alex Chen
Alex Chen

Posted on

How I Structure My Node.js Projects (2026)

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

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

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

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

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

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

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

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

.gitignore Essentials

node_modules/
dist/
build/
.env
*.log
npm-debug.log*
coverage/
.nyc_output/
.DS_Store
data/*.db
data/*.db-journal
!.env.example
Enter fullscreen mode Exit fullscreen mode

Testing Structure

tests/
├── unit/
│   ├── services/
│   │   └── userService.test.js
│   └── utils/
│       └── response.test.js
├── integration/
│   └── api/
│       └── users.test.js
└── fixtures/
    └── users.json
Enter fullscreen mode Exit fullscreen mode
// 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');
  });
});
Enter fullscreen mode Exit fullscreen mode

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

What's your go-to project structure? Do you agree or disagree?

Follow @armorbreak for more Node.js guides.

Top comments (0)