During my time as a Backend Developer at Badr Interactive, I built RESTful APIs consumed by frontend developers and third-party services. Here's the structure I keep coming back to for a Node.js + Express.js project.
A common mistake is stuffing everything into a single app.js. Instead, split responsibilities:
routes/ → define endpoints
controllers/ → handle requests & responses
services/ → business logic
models/ → database schemas
This separation makes your codebase easier to navigate. When a bug is reported, you know exactly where to look. Routes shouldn't contain business logic — they should just delegate to controllers. Controllers shouldn't talk directly to the database — they should call services. This layered approach keeps each file focused and testable.
Authentication, logging, and error handling belong in middleware — not repeated in every route. This keeps handlers focused and your code DRY.
Create middleware for common tasks:
// auth.middleware.js
const authMiddleware = (req, res, next) => {
const token = req.headers.authorization;
if (!token) return res.status(401).json({ error: 'Unauthorized' });
// verify token and attach user to req
next();
};
Apply it to routes that need protection:
router.post('/admin', authMiddleware, adminController.create);
This pattern keeps your route handlers clean and focused on their specific job.
Frontend devs love a predictable API. Wrap errors in a standard shape:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid email format"
}
}
Create an error handling middleware that formats all errors consistently:
const errorHandler = (err, req, res, next) => {
const statusCode = err.statusCode || 500;
res.status(statusCode).json({
error: {
code: err.code || 'INTERNAL_ERROR',
message: err.message || 'Something went wrong'
}
});
};
This makes it easy for frontend developers to handle errors gracefully and display appropriate messages to users.
Whether you use SQL or a NoSQL store, design your schema around the queries you'll actually run. Index the fields you filter on, and keep migrations in version control.
For SQL databases, use an ORM or query builder to prevent SQL injection and make queries more readable:
// Using a query builder
const users = await db('users')
.where({ email: req.body.email })
.select('id', 'name', 'email');
Always validate input before it hits your database. Use libraries like Joi or Zod for schema validation:
const schema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(8).required()
});
const { error, value } = schema.validate(req.body);
if (error) return res.status(400).json({ error: error.details });
Use tools like Swagger/OpenAPI to generate interactive documentation. Good documentation reduces support questions and helps new developers onboard faster. Include request/response examples, authentication details, and error codes for each endpoint.
The goal isn't clever code — it's an API that's easy to consume, easy to test, and easy to grow. That's what makes the frontend team (and your future self) happy.
— Fegi Sucepto Priawan · UI/UX Designer & Backend Developer