Best practices to consider when coding with Express.js - mhmunem/Grocery-Comparison-App GitHub Wiki
Divide the application into separate modules, such as routing, controllers, and middleware, to improve code organization and maintainability. Example:- We have a basic Express.js application with the following structure.
-app.js
-routes/
-index.js
-user.js
app.js
const express = require('express');
const app = express();
const indexRoutes = require('./routes/index');
const usersRoutes = require('./routes/users');
app.use('/', indexRoutes);
app.use('/users', usersRoutes);
app.listen(3000, () => {
console.log('Server is running on http://localhost:3000');
});
routes/index.js
const express = require('express');
const router = express.Router();
router.get('/', (req, res) => {
res.send('Welcome to the homepage');
});
module.export = router;
routes/users.js
const express = require('express');
const router = express.Router();
router.get('/', (req, res) => {
res.send('List of users');
});
router.get('/:id', (req, res) => {
res.send('User with ID ${req.params.id}');
});
module.exports = router;
With this modular design, app.js is mainly responsible for the overall setup of the application, while the individual routing modules handle their own paths and logic, making it easy to add, remove or modify routes.
Middleware is used to handle tasks such as logging, data validation, authentication and error handling, ensuring a clear and maintainable request processing flow.
- Example:- Let’s add an example of middleware to our previous modularized Express.js application to demonstate how middleware functions work:
const express = require('express');
const app = express();
//Middleware function to log incoming requests
const loggerMiddleware = (req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
next();
};
app.use(loggerMiddleware);
const indexRoutes = require('./routes/index');
const usersRoutes = require('./routes/users');
app.use('/', indexRoutes);
app.use('/users', usersRoutes);
app.listen(3000, () => {
console.log('Server is running on http://localhost:3000');
});
In this example, loggerMiddleware logs each incoming request's method, URL, and timestamp, then calls next() to pass control to the next middleware.
Implementing a global error handling mechanism captures and manages errors within the application, returning appropriate error messages to the client.
- Example:- Let’s add error handling to our previous modularized Express.js application to demonstate how it works:
//app.js
const express = require('express');
const app = express();
// Middleware function to log incoming requests
const loggerMiddleware = (req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
next();
};
app.use(loggerMiddleware);
const indexRoutes = require('./routes/index');
const usersRoutes = require('./routes/users');
app.use('/', indexRoutes);
app.use('/users', usersRoutes);
// Error handling middleware
app.use((err, req, res, next) => {
console.error('Error:', err.message);
res.status(500).send('Something went wrong. Please try again later.');
});
app.listen(3000, () => {
console.log('Server is running on http://localhost:3000');
});
//routes/users.js
const express = require('express');
const router = express.Router();
router.get('/:id', (req, res, next) => {
const userId = parseInt(req.params.id);
if (isNaN(userId)) {
// Create a new Error object and pass it to the next middleware
return next(new Error('Invalid user ID'));
}
// Your regular route handling logic here...
// For example, fetch the user from the database and send the response
res.send(`User with ID ${userId}`);
});
module.exports = router;
We modified the routes/users.js file to introduce an error if the provided user ID is not a valid integer. If the user ID is not a number, we create a new Error object with the message “Invalid user ID” and pass it to the next function to trigger the error-handling middleware. The error-handling middleware in app.js will then ccatch the error and respond to the client with a 500 Internal Server Error and a generic error message.
Data validation involves checking the data against predefined rules and constraints before processing it. This can include checking data types, length, format, and other business-specific validation criteria.
- Example:- Let’s add data validation to our previous modularized Express.js application to demonstrate how it works:
//routes/users.js
const express = require('express');
const router = express.Router();
// Data validation middleware
const validateUserId = (req, res, next) => {
const userId = parseInt(req.params.id);
if (isNaN(userId)) {
return res.status(400).send('Invalid user ID');
}
// Attach the parsed userId to the request object for future use
req.userId = userId;
next();
};
router.get('/:id', validateUserId, (req, res) => {
// The user ID is now available as req.userId, which is already validated
const userId = req.userId;
// Your regular route handling logic here...
// For example, fetch the user from the database and send the response
res.send(`User with ID ${userId}`);
});
module.exports = router;
We added a validateUserId middleware function that checks if the provided user ID is a valid integer. If the ID is not a number, we respond with a 400 Bad Request status and an error message. If the ID is valid, we attach the parsed userId to the req object for future use in the route handler. Now, when a client makes a request to the /users/:id endpoint with an invalid user ID (e.g., /users/abc), the server will respond with a 400 error, indicating that the provided ID is invalid. If the ID is valid, the request proceeds to the route handler.
Some common security practices in Express.js include input validation to prevent SQL injection and XSS attacks, implementing authentication and authorization to control user access, using secure communication protocols (HTTPS), and protecting against CSRF attacks.
- Example:- Let’s add input validation to our previous modularized Express.js application to demonstrate how it works: In this example, we’ll add input validation to prevent SQL injection when fetching a user from the database.
const express = require('express');
const router = express.Router();
const db = require('../db'); // Example database module
// Data validation middleware
const validateUserId = (req, res, next) => {
const userId = parseInt(req.params.id);
if (isNaN(userId)) {
return res.status(400).send('Invalid user ID');
}
// Attach the parsed userId to the request object for future use
req.userId = userId;
next();
};
router.get('/:id', validateUserId, async (req, res) => {
const userId = req.userId;
try {
// Fetch the user from the database using a prepared statement
const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
if (user.length === 0) {
return res.status(404).send('User not found');
}
// Your regular route handling logic here...
res.send(user);
} catch (err) {
console.error('Error:', err);
res.status(500).send('Internal Server Error');
}
});
module.exports = router;
We used a prepared statement ('SELECT * FROM users WHERE id = ?') to fetch the user from the database. The user ID provided in the request is validated using the validateUserId middleware to ensure it's a valid integer. Using prepared statements protects against SQL injection by automatically escaping user-supplied data.
By adding input validation and using secure database query methods, you can prevent common security vulnerabilities like SQL injection, which could lead to data breaches and unauthorized access.
async/await is a syntactical feature introduced in ES6 (ECMAScript 2017) that allows you to write asynchronous code using synchronous syntax. It is built on top of Promises and provides a more concise way to handle promises and avoid nested callback functions.
- Example:- Let’s modify our previous example in the routes/users.js file to use async/await for database operations:
//routes/users.js
const express = require('express');
const router = express.Router();
const db = require('../db'); // Example database module
// Data validation middleware
const validateUserId = (req, res, next) => {
const userId = parseInt(req.params.id);
if (isNaN(userId)) {
return res.status(400).send('Invalid user ID');
}
// Attach the parsed userId to the request object for future use
req.userId = userId;
next();
};
router.get('/:id', validateUserId, async (req, res) => {
const userId = req.userId;
try {
// Fetch the user from the database using async/await
const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
if (user.length === 0) {
return res.status(404).send('User not found');
}
// Your regular route handling logic here...
res.send(user);
} catch (err) {
console.error('Error:', err);
res.status(500).send('Internal Server Error');
}
});
module.exports = router;
We used async before the route handler function to indicate that it contains asynchronous code. We also used await to wait for the result of the db.query() function, which returns a Promise. By using await, we can get the resolved value of the Promise directly and handle it in a more linear and readable manner.
async/await makes the code more straightforward and easier to follow, especially when dealing with multiple asynchronous operations. It helps avoid the "callback hell" problem that often arises with deeply nested callbacks.
Note: To use async/await, make sure our Node.js version supports it (Node.js 8 or later) without the need for additional transpilers or flags.