Node.js, combined with Express.js, has become a go-to framework for building fast, scalable web applications. To ensure reliability, performance, and maintainability, it’s essential to follow best coding practices when developing with Node and Express. In this guide, we’ll cover key practices to write clean, efficient, and robust code that can scale with ease.
1. Organize Your Project Structure
A well-structured project is the foundation of a maintainable codebase. Node doesn’t enforce a particular structure, so it’s up to developers to keep things organized.
Basic Directory Layout:
├── controllers
├── models
├── routes
├── middlewares
├── utils
└── app.js
- Controllers: Handle requests and responses.
- Models: Define data structures (typically for MongoDB, MySQL, etc.).
- Routes: Define all application routes.
- Middlewares: Hold reusable functions (e.g., authentication, error handling).
- Utils: For helper functions or constants. This organization ensures that responsibilities are clear, which makes debugging and scaling easier.
2. Use Environment Variables
Hardcoding sensitive information, such as database credentials, in code is insecure and inflexible. Use .env
files to manage configuration data.
DB_HOST=localhost
DB_USER=root
DB_PASS=secret
JWT_SECRET=my_jwt_secret
Usage in Code
require('dotenv').config();
const dbHost = process.env.DB_HOST;
This practice also allows for easy configuration changes between development and production environments without altering the source code.
3. Handle Errors Gracefully
Express has robust error-handling capabilities. Use middleware to capture errors and provide meaningful responses.
Basic Error Handling Middleware:
// Middleware for catching errors
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Something went wrong!' });
});
Async Error Handling
When using async functions, wrap them in a try-catch block to avoid unhandled promise rejections:
app.get('/route', async (req, res, next) => {
try {
const data = await someAsyncFunction();
res.json(data);
} catch (error) {
next(error); // Passes errors to the error-handling middleware
}
});
Centralizing error handling makes troubleshooting easier and ensures consistent responses to the client.
4. Follow RESTful API Design
Following REST principles makes your API predictable and easy to understand. Here are some essential rules:
Use proper HTTP verbs:
- GET for retrieving resources.
- POST for creating resources.
- PUT/PATCH for updating resources.
- DELETE for deleting resources.
- Use nouns in URLs instead of actions (e.g.,
/users
instead of/getUsers
). - Nest URLs logically (e.g.,
/users/:userId/orders/:orderId
).
Keeping your API RESTful improves readability and consistency, especially when working with frontend teams.
5. Utilize Middleware
Express middleware enhances code modularity by letting you apply reusable functions to requests.
Examples of Common Middleware Uses:
- Authentication and Authorization: For example, JWT-based authentication can be applied to protected routes.
const authenticate = (req, res, next) => {
// Logic for token verification
next();
};
app.use('/protected', authenticate);
- Data Validation: Use libraries like Joi to validate incoming data.
const Joi = require('joi');
const schema = Joi.object({ name: Joi.string().min(3).required() });
app.post('/data', (req, res, next) => {
const { error } = schema.validate(req.body);
if (error) return res.status(400).json({ error: error.details[0].message });
next();
});
Using middleware keeps your main application logic clean and focused.
6. Use Async/Await and Avoid Callback Hell
Callbacks can make your code difficult to read and prone to bugs. async/await simplifies asynchronous code and makes it more readable.
// Instead of nested callbacks
doSomething((err, result1) => {
if (err) return handleError(err);
doSomethingElse(result1, (err, result2) => {
if (err) return handleError(err);
doFinalThing(result2, (err, finalResult) => {
if (err) return handleError(err);
console.log(finalResult);
});
});
});
// Use async/await for better readability
const process = async () => {
try {
const result1 = await doSomething();
const result2 = await doSomethingElse(result1);
const finalResult = await doFinalThing(result2);
console.log(finalResult);
} catch (error) {
handleError(error);
}
};
This practice reduces errors and makes your asynchronous code flow more intuitive.
7. Leverage a Linter and Formatter
Using a linter like ESLint ensures that your code adheres to a consistent style and helps catch common errors. A formatter like Prettier can keep your code visually consistent.
Set up ESLint and Prettier:
npm install eslint prettier eslint-config-prettier eslint-plugin-prettier --save-dev
Add configuration files for ESLint and Prettier in your project root to define your coding style and enforce standards.
Automated code formatting and linting reduce the likelihood of errors and improve readability for your team.
8. Limit Payload Sizes and Rate Limit API Requests
Restrict the size of JSON payloads to prevent abuse and ensure application security. You can use express.json()
to limit payload sizes:
app.use(express.json({ limit: '10kb' })); // Limit requests to 10kb
Implement Rate Limiting: Use a package like express-rate-limit to prevent DDoS attacks by limiting the number of requests a client can make.
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // limit each IP to 100 requests per windowMs
});
app.use(limiter);
Rate limiting protects your API from excessive usage, helping maintain performance and security.
9. Optimize Database Queries and Caching
Database queries can be time-consuming, especially when handling large datasets. Use indexing, optimized queries, and caching to minimize load times.
Caching Example: Use a service like Redis to cache data that doesn’t change frequently.
const redis = require('redis');
const client = redis.createClient();
const cacheMiddleware = (req, res, next) => {
const { id } = req.params;
client.get(id, (err, data) => {
if (err) throw err;
if (data) {
res.send(JSON.parse(data));
} else {
next();
}
});
};
Caching helps improve response times and reduces load on the database.
10. Write Tests for Your Code
Testing is essential for a robust application. Use a testing framework like Jest or Mocha to create unit and integration tests for your application.
Basic Jest Test Example:
const request = require('supertest');
const app = require('../app');
describe('GET /users', () => {
it('should return an array of users', async () => {
const response = await request(app).get('/users');
expect(response.statusCode).toBe(200);
expect(Array.isArray(response.body)).toBeTruthy();
});
});
Writing tests ensures that new changes don’t break existing functionality, enabling you to develop with confidence.
Conclusion
Following best practices when working with Node.js and Express.js is essential for building secure, scalable, and maintainable applications. By organizing your project, handling errors properly, using environment variables, following REST principles, and leveraging middleware, you create a codebase that’s efficient and easy to manage. Taking steps like caching data, rate-limiting API requests, and writing tests ensures that your application performs reliably under various conditions.
As your project grows, revisiting and refining these practices will help maintain the quality of your application and make it easy to work on as part of a team.