Skip to main content

Express Server Notes

Updated 17 min readMarkdown

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.js and 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 request

  • res → response

  • next → continue to the next step

Middleware can:

  • authenticate

  • validate

  • log

  • modify req

  • block 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.

18 views

About the author

No comments yet. Be the first to comment.