Express Server Notes
What problem does each part solve, and how does a request travel through the backend?
1. Server setup:
Import packages
↓
Create Express app
↓
Add middleware
↓
Connect database
↓
Create auth/helpers
↓
Create routes/controllers
↓
Start server
When a request arrives:
CLIENT
↓
REQUEST
↓
MIDDLEWARE
↓
CONTROLLER
↓
DATABASE
↓
RESPONSE
2. Importing Packages
const express = require('express');
const mongoose = require('mongoose');
const jwt = require('jsonwebtoken');
Express → creates the backend/server.
Mongoose → communicates with MongoDB.
jsonwebtoken → creates and verifies JWTs.
Node.js → Express → Server
Node.js → Mongoose → MongoDB
Node.js → JWT → Authentication
3. Importing Our Own Models
const User = require('./User');
const Client = require('./Client');
./User means:
Go to
User.jsand give me what that file exports.
The models define how our MongoDB data is structured.
4. Creating the Express App
const app = express();
express() creates an Express application.
We store it in app, which is why we can use:
app.get(...)
app.post(...)
app.use(...)
app.listen(...)
Think:
express()
↓
Express application
↓
app
5. JSON Middleware
app.use(express.json());
If Postman sends:
{
"bio": "I am learning backend"
}
express.json() parses it so we can access:
req.body
Therefore:
JSON request
↓
express.json()
↓
req.body
6. Connecting MongoDB
await mongoose.connect(
'mongodb://127.0.0.1:27017/backendPractice'
);
Meaning:
127.0.0.1 → this computer
27017 → MongoDB port
backendPractice → database name
A clean startup flow is:
startServer()
↓
connect MongoDB
↓
successful?
↙ ↘
yes no
↓ ↓
listen error
So the server starts accepting requests only after the database connection succeeds.
7. .then() and .catch()
For a promise:
mongoose.connect(...)
.then(() => {
console.log('Connected');
})
.catch((error) => {
console.log(error);
});
Think:
Try operation
↓
┌───────┴───────┐
success failure
↓ ↓
then catch
8. Middleware
Middleware sits between the request and the controller.
REQUEST
↓
middleware
↓
controller
↓
response
It receives:
(req, res, next)
req→ incoming requestres→ responsenext→ continue to the next step
Middleware can:
authenticate
validate
log
modify
reqblock requests
9. req.params, req.body, req.headers
These are three important sources of request information.
req.params
For:
app.get('/generate-token/:userId', ...)
Request:
/generate-token/ABC123
Then:
req.params.userId
is:
ABC123
Used for values inside the URL.
req.body
For:
{
"bio": "Hello"
}
we get:
req.body.bio
Used for data sent inside the request body.
req.headers
For:
Authorization: Bearer XYZ...
we can access:
req.headers.authorization
Used for request metadata such as authentication information.
10. Getting the JWT
const token =
req.headers.authorization?.split(' ')[1];
Suppose:
Bearer eyJhb...
After:
.split(' ')
we get:
["Bearer", "eyJhb..."]
[1] gives:
eyJhb...
So the flow is:
Authorization header
↓
"Bearer TOKEN"
↓
split(' ')
↓
["Bearer", "TOKEN"]
↓
[1]
↓
TOKEN
11. Optional Chaining ?.
Without:
authorization.split(' ')
if authorization doesn't exist, JavaScript can crash.
With:
authorization?.split(' ')
JavaScript says:
If it exists, continue. Otherwise give
undefined.
Use ?. when something may legitimately be missing.
It is different from try/catch:
?. → safely access missing value
try/catch → handle an operation that throws
12. Authentication Middleware
A simple authentication flow:
const auth = (req, res, next) => {
const token =
req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({
message: 'No token provided'
});
}
try {
const decoded = jwt.verify(
token,
'mysecretkey'
);
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({
message: 'Invalid token'
});
}
};
The mental model:
Request
↓
Get token
↓
Token exists?
↓
Verify JWT
↓
Valid?
↙ ↘
NO YES
↓ ↓
401 req.user
↓
next()
13. Why req.user = decoded?
Suppose the JWT contains:
{
userId: "12345"
}
After verification:
req.user = decoded;
Now later code can use:
req.user.userId
The flow is:
JWT
↓
verify
↓
decoded data
↓
req.user
↓
controller
14. Why next()?
next() means:
Authentication succeeded. Continue the request pipeline.
If authentication fails:
return res.status(401).json(...)
The request stops.
If authentication succeeds:
next();
The request continues.
Think of middleware as a security checkpoint:
Request
↓
Security check
↓
┌───────┴───────┐
fail success
↓ ↓
STOP next()
15. Helper Functions
Example:
const formatClient = (client) => {
return {
savedLawyers: client.savedLawyers,
blockedLawyers: client.blockedLawyers,
bio: client.bio
};
};
A helper takes existing data and creates the format we want to send.
MongoDB document
↓
formatClient()
↓
clean response
This prevents us from sending unnecessary fields.
16. Controller
Example:
app.get('/api/client/profile', auth, async (req, res) => {
...
});
There are three parts:
/api/client/profile → URL
auth → middleware
async (req, res) → controller logic
The controller answers:
What should happen when this endpoint is called?
For a profile request:
Get user ID
↓
Find User
↓
Find Client
↓
Format data
↓
Send response
17. Finding User and Client
const id = req.user.userId;
const user = await User.findOne({
_id: id
});
const client = await Client.findOne({
userId: id
});
The User has:
_id = 12345
The Client has:
userId = 12345
So they are connected:
USER
_id = 12345
↑
│
CLIENT
userId = 12345
If either doesn't exist:
if (!user) {
return res.status(404).json({
message: 'User not found'
});
}
18. Sending Response
return res.status(200).json({
userProfile: formatUser(user),
clientProfile: formatClient(client)
});
200 means the request succeeded.
The response is sent back to Postman/frontend.
19. try/catch in Controllers
Database operations can throw errors:
try {
const user = await User.findOne(...);
}
catch (error) {
res.status(500).json({
message: 'Server error'
});
}
Think:
Expected problem
↓
if check
↓
401 / 404 / etc.
Unexpected thrown error
↓
try/catch
↓
500
20. Updating Profile
app.put('/api/client/profile', auth, async (req, res) => {
...
});
Remember:
GET → retrieve
PUT → update
Example:
const client = await Client.findOneAndUpdate(
{ userId: id },
{ bio: req.body.bio },
{ new: true }
);
Three objects:
1. What to find
{ userId: id }
2. What to change
{ bio: req.body.bio }
3. What to return
{ new: true }
new: true means:
Return the updated document instead of the old one.
21. JWT Creation
const token = jwt.sign(
{ userId: req.params.userId },
'mysecretkey',
{ expiresIn: '1h' }
);
This creates a JWT containing:
{
userId: "ABC123"
}
with:
secret → mysecretkey
expiry → 1 hour
Then:
res.json({
token: token
});
sends it to the client.
The client can later send:
Authorization: Bearer TOKEN
22. Middleware vs Controller
This is the important distinction.
Ask:
Is this checking/preparing something before the actual operation?
If yes → middleware
Ask:
Is this actually performing what the endpoint was requested to do?
If yes → controller
Middleware
Check authentication
Check permissions
Validate
Prepare request
↓
next()
Controller
Perform endpoint's job
↓
Database
↓
Business logic
↓
Response
For example:
GET /profile
↓
auth ← middleware
↓
getProfile ← controller
↓
MongoDB
↓
response
23. Complete Mental Model
Whenever you read Express code, ask:
1. Where did the information come from?
↓
params / body / query / headers
2. What needs to be checked first?
↓
middleware
3. What is this endpoint supposed to do?
↓
controller
4. Where does the data come from/go?
↓
database
5. What do we send back?
↓
response
The whole backend can therefore be understood as:
REQUEST
↓
Where is the data?
(params/body/headers)
↓
MIDDLEWARE
(check/prepare/block)
↓
CONTROLLER
(actual endpoint work)
↓
DATABASE
↓
RESPONSE
Express backend code is essentially a request entering the system, passing through checkpoints, performing an operation, talking to the database, and finally returning a response.
HOW TO STRUCTURE THE SAME FOR A LARGE PROJECT
server/
│
├── server.js
├── package.json
├── .env
│
├── config/
│ ├── db.js
│ └── env.js
│
├── models/
│ ├── User.js
│ └── Client.js
│
├── controllers/
│ ├── auth.controller.js
│ └── client.controller.js
│
├── routes/
│ ├── auth.routes.js
│ └── client.routes.js
│
├── middleware/
│ ├── auth.middleware.js
│ ├── error.middleware.js
│ └── validation.middleware.js
│
├── services/
│ ├── auth.service.js
│ └── client.service.js
│
├── utils/
│ ├── jwt.js
│ ├── formatters.js
│ └── asyncHandler.js
│
└── validators/
├── auth.validator.js
└── client.validator.js
server.js
const express = require('express');
const connectDB = require('./config/db');
const authRoutes = require('./routes/auth.routes');
const clientRoutes = require('./routes/client.routes');
const errorHandler = require('./middleware/error.middleware');
const app = express();
app.use(express.json());
connectDB();
app.use('/api/auth', authRoutes);
app.use('/api/client', clientRoutes);
app.get('/', (req, res) => {
res.json({
message: 'Server is running'
});
});
app.use(errorHandler);
app.listen(5000, () => {
console.log('Server running on http://localhost:5000');
});
config/db.js
const mongoose = require('mongoose');
const connectDB = async () => {
try {
await mongoose.connect(
'mongodb://127.0.0.1:27017/backendPractice'
);
console.log('MongoDB connected');
} catch (error) {
console.log('MongoDB connection failed');
console.log(error);
}
};
module.exports = connectDB;
models/User.js
const mongoose = require('mongoose');
const userSchema = new mongoose.Schema({
name: String,
email: String,
phone: String,
role: String,
isEmailVerified: Boolean,
isPhoneVerified: Boolean,
isActive: Boolean
});
module.exports = mongoose.model('User', userSchema);
models/Client.js
const mongoose = require('mongoose');
const clientSchema = new mongoose.Schema({
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
},
savedLawyers: [
{
type: mongoose.Schema.Types.ObjectId
}
],
blockedLawyers: [
{
type: mongoose.Schema.Types.ObjectId
}
],
bio: String
});
module.exports = mongoose.model('Client', clientSchema);
middleware/auth.middleware.js
const jwt = require('jsonwebtoken');
const auth = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({
message: 'No token provided'
});
}
try {
const decoded = jwt.verify(
token,
'mysecretkey'
);
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({
message: 'Invalid token'
});
}
};
module.exports = auth;
middleware/error.middleware.js
const errorHandler = (error, req, res, next) => {
console.log(error);
res.status(500).json({
message: 'Server error'
});
};
module.exports = errorHandler;
utils/formatters.js
const formatClient = (client) => {
return {
savedLawyers: client.savedLawyers,
blockedLawyers: client.blockedLawyers,
bio: client.bio
};
};
const formatUser = (user) => {
return {
name: user.name,
email: user.email,
phone: user.phone,
role: user.role,
isEmailVerified: user.isEmailVerified,
isPhoneVerified: user.isPhoneVerified,
isActive: user.isActive
};
};
module.exports = {
formatClient,
formatUser
};
utils/jwt.js
const jwt = require('jsonwebtoken');
const generateToken = (userId) => {
return jwt.sign(
{
userId: userId
},
'mysecretkey',
{
expiresIn: '1h'
}
);
};
module.exports = {
generateToken
};
utils/asyncHandler.js
const asyncHandler = (fn) => {
return (req, res, next) => {
Promise
.resolve(fn(req, res, next))
.catch(next);
};
};
module.exports = asyncHandler;
controllers/client.controller.js
const User = require('../models/User');
const Client = require('../models/Client');
const {
formatClient,
formatUser
} = require('../utils/formatters');
const asyncHandler = require('../utils/asyncHandler');
const getMyProfile = asyncHandler(async (req, res) => {
const id = req.user.userId;
const user = await User.findOne({
_id: id
});
const client = await Client.findOne({
userId: id
});
if (!user) {
return res.status(404).json({
message: 'User not found'
});
}
if (!client) {
return res.status(404).json({
message: 'Client not found'
});
}
const formattedUser = formatUser(user);
const formattedClient = formatClient(client);
return res.status(200).json({
userProfile: formattedUser,
clientProfile: formattedClient
});
});
const updateProfile = asyncHandler(async (req, res) => {
const id = req.user.userId;
const client = await Client.findOneAndUpdate(
{
userId: id
},
{
bio: req.body.bio
},
{
new: true
}
);
if (!client) {
return res.status(404).json({
message: 'Client not found'
});
}
return res.status(200).json({
message: 'Profile updated successfully',
clientProfile: formatClient(client)
});
});
module.exports = {
getMyProfile,
updateProfile
};
controllers/auth.controller.js
const User = require('../models/User');
const Client = require('../models/Client');
const {
generateToken
} = require('../utils/jwt');
const createTestUser = async (req, res) => {
const user = await User.create({
name: 'Jyoti',
email: 'jyoti@test.com',
phone: '9999999999',
role: 'client',
isEmailVerified: true,
isPhoneVerified: true,
isActive: true
});
const client = await Client.create({
userId: user._id,
savedLawyers: [],
blockedLawyers: [],
bio: 'I am learning backend development.'
});
res.json({
message: 'Test user created',
userId: user._id,
user,
client
});
};
const getToken = (req, res) => {
const token = generateToken(
req.params.userId
);
res.json({
token
});
};
module.exports = {
createTestUser,
getToken
};
routes/client.routes.js
const express = require('express');
const auth = require('../middleware/auth.middleware');
const {
getMyProfile,
updateProfile
} = require('../controllers/client.controller');
const router = express.Router();
router.get(
'/profile',
auth,
getMyProfile
);
router.put(
'/profile',
auth,
updateProfile
);
module.exports = router;
routes/auth.routes.js
const express = require('express');
const {
createTestUser,
getToken
} = require('../controllers/auth.controller');
const router = express.Router();
router.get(
'/create-test-user',
createTestUser
);
router.get(
'/generate-token/:userId',
getToken
);
module.exports = router;
Final structure
server/
│
├── server.js
├── package.json
├── .env
│
├── config/
│ └── db.js
│
├── models/
│ ├── User.js
│ └── Client.js
│
├── controllers/
│ ├── auth.controller.js
│ └── client.controller.js
│
├── routes/
│ ├── auth.routes.js
│ └── client.routes.js
│
├── middleware/
│ ├── auth.middleware.js
│ └── error.middleware.js
│
└── utils/
├── jwt.js
├── formatters.js
└── asyncHandler.js
The basic role of each file was already explained. The important next step is understanding how the files communicate and why large projects separate responsibilities.
1. How files communicate
Node files communicate mainly through:
module.exports
and:
require()
For example:
// utils/jwt.js
module.exports = {
generateToken
};
Another file can use it:
const { generateToken } = require('../utils/jwt');
Think:
jwt.js
│
│ exports
▼
generateToken
│
│ require
▼
auth.controller.js
./ vs ../
require('./file')
→ current folder
require('../file')
→ go one folder up first
2. express.Router()
Putting every route inside server.js becomes messy:
app.get(...)
app.post(...)
app.put(...)
app.delete(...)
So we create separate routers:
const router = express.Router();
For example:
routes/
├── auth.routes.js
├── client.routes.js
├── lawyer.routes.js
└── admin.routes.js
Each router manages one group of endpoints.
3. How route paths combine
In server.js:
app.use('/api/client', clientRoutes);
In client.routes.js:
router.get('/profile', auth, getMyProfile);
Express combines them:
/api/client
+
/profile
=
/api/client/profile
So the actual request is:
GET /api/client/profile
This lets server.js handle the main prefixes, while route files handle the individual endpoints.
4. Why controllers are passed without ()
Route:
router.get('/profile', auth, getMyProfile);
We pass:
getMyProfile
not:
getMyProfile()
Because:
getMyProfile
means:
"Here is the function. Run it when the request arrives."
Whereas:
getMyProfile()
means:
"Run it right now."
5. Middleware and controllers form a pipeline
For:
GET /api/client/profile
the flow is:
Request
↓
server.js
↓
client router
↓
auth middleware
↓
next()
↓
getMyProfile controller
↓
User/Client model
↓
MongoDB
↓
res.json()
↓
Response
The files are separate, but the request is still one continuous pipeline.
6. asyncHandler
Without asyncHandler, every async controller may need:
try {
...
}
catch(error) {
next(error);
}
That becomes repetitive across hundreds of controllers.
So we can create:
const asyncHandler = (fn) => {
return (req, res, next) => {
Promise
.resolve(fn(req, res, next))
.catch(next);
};
};
Then:
const getMyProfile = asyncHandler(async (req, res) => {
const user = await User.findOne(...);
});
Conceptually:
Controller
↓
asyncHandler
↓
success → continue
error → next(error)
7. Error middleware
Normal middleware:
(req, res, next)
Error middleware:
(error, req, res, next)
Example:
const errorHandler = (error, req, res, next) => {
res.status(500).json({
message: 'Server error'
});
};
Then:
app.use(errorHandler);
So an error can travel:
Controller
↓
asyncHandler
↓
next(error)
↓
errorHandler
↓
500 response
8. Route vs Controller
This distinction is extremely important.
Route asks:
Which request should trigger something?
router.get('/profile', auth, getMyProfile);
Controller asks:
What should happen?
const getMyProfile = asyncHandler(async (req, res) => {
// actual request logic
});
Think:
ROUTE → WHERE?
CONTROLLER → WHAT?
9. Why do we need services?
A controller can become huge if it contains:
database logic
business rules
calculations
permissions
notifications
formatting
...
So complicated reusable business logic can move into a service.
Route
↓
Controller
↓
Service
↓
Model
↓
Database
Example:
// client.service.js
const getClientProfile = async (userId) => {
const user = await User.findOne({ _id: userId });
const client = await Client.findOne({ userId });
return { user, client };
};
Then the controller stays simple:
const getMyProfile = asyncHandler(async (req, res) => {
const data = await getClientProfile(req.user.userId);
res.json(data);
});
The service is especially useful when the same logic is needed by multiple controllers.
10. .env
Instead of hardcoding:
'mongodb://...'
'mysecretkey'
we can store configuration in:
.env
Example:
PORT=5000
MONGO_URI=...
JWT_SECRET=...
Then:
process.env.MONGO_URI
process.env.JWT_SECRET
This allows the same code to work with different environments such as:
development
testing
production
without changing the JavaScript code.
11. Model vs Database
This is another important distinction.
const User = require('../models/User');
User is a Mongoose model, not the database itself.
The flow is:
Controller
↓
User model
↓
Mongoose
↓
MongoDB
For example:
User.findOne(...)
means:
Ask the User model to perform a database operation.
12. Complete architecture
A large Express backend can therefore look like:
CLIENT
↓
SERVER
↓
ROUTES
↓
MIDDLEWARE
↓
CONTROLLER
↓
SERVICE
↓
MODEL
↓
MONGOOSE
↓
DATABASE
Errors have their own path:
Any layer
↓
next(error)
↓
Error middleware
↓
Response
13. The mental model to remember
Don't memorize the folder names.
Ask these questions:
Which URL?
↓
ROUTE
Does something need checking first?
↓
MIDDLEWARE
What should happen?
↓
CONTROLLER
Is there reusable/complex business logic?
↓
SERVICE
How do we access stored data?
↓
MODEL
Where is the data actually stored?
↓
DATABASE
The core idea
A large project doesn't separate files because the request itself is different.
It separates them because different responsibilities are easier to manage when they live in different places.
So instead of:
10,000 lines → one server.js
we get:
10,000 lines
↓
many small responsibilities
↓
many focused files
↓
one connected request pipeline
That is what makes a large backend manageable.
About the author
- MERN || FARM || System Design || Devops
Comments
Join the discussionNo comments yet. Be the first to comment.