Express.js Basics & Beyond
Express is a minimalistic and flexible Node.js backend framework that provides robust features for building APIs. In this short series, we’ll explore the core features of Express.js.
Why use Express:
- Minimalism
Express.js has all the essential tools for building APIs from scratch. The code is simple, readable, and easy to get started. - Popularity
Express.js is used in many Node.js applications and sometimes with other runtimes like Deno and Bun. - Huge ecosystem
Express.js has been out for a long time. Due to its large adoption by the Node.js community, many third-party packages have been created over the years to extend the framework features. - Out-of-the-box support for routing, middlewares, error handling, etc.
- If you’re good with vanilla Node.js, you’ll get around Express.js quickly.
- The basis for frameworks like Next.js and Nest.js
Setting up the project
Express.js is a Node.js framework, so you need Node.js installed to get Express. Once you set up Node.js, use the npm init
command to initialize a new NPM project:
> npm init -y
Wrote to D:\Medium\express-basics-and-beyond-medium\package.json:
{
"name": "express-basics-and-beyond-medium",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
Then install Express.js:
> npm i express
added 65 packages, and audited 66 packages in 2s
Create an app.js file in the root folder and set up a basic server.
// app.js
const express = require('express');
const app = express();
app.listen(3000, () => {
console.log('Server started on port 3000!');
});
Brief explanation
- Two lines at the top import the Express package and create an instance.
const express = require('express');
const app = express();
- The line at the bottom is in charge of running the server on a specific port. It accepts two parameters: the port number and the callback function, which will fire when the server starts.
app.listen(3000, () => {
console.log('Server started on port 3000!');
});
You can choose any port that is not used on your machine. In this case, I chose the port 3000. If you run the app.js from a terminal, you’ll see the message printed in the console:
> node app.js
Server started on port 3000!
Then switch to your browser and go to http://localhost:3000/ and see what happens.
And there is an error. Why?
For starters, visiting the URL manifests as a GET request to the server. However, there aren’t any routes delegated to handle this request.
The server doesn’t know what to do.
First Route
The app instance exposes a wide range of extension methods for various HTTP methods, including:
app.get()
for GET requestsapp.post()
for POST requestsapp.put()
for PUTapp.patch()
for PATCHapp.delete()
for DELETEapp.all()
for ALL route variants, etc.
Each method handler takes at least two parameters:
- Route URI and (
app.get(‘/URI-path’)
) - The callback method that handles the response
(app.get(‘/path’, (req, res) => {...})
).
The callback method also comes with two parameters: the Request
and Response
objects. More on this later.
Here is how you create a simple get-request route:
const express = require('express');
const app = express();
// GET request sample
app.get('/', (req, res) => {
res.send('Hello World!');
});
app.listen(3000, () => {
console.log('Server started on port 3000!');
});
Stop the server (using CTRL/CMD+C) and rerun the node app.js
. Go back to the browser and hit the reload key.
The response ‘Hello World!’ appears on the screen, and the error disappears.
Multiple Routes
Express.js lets you define as many routes as you desire and give each a unique response:
However, the order of precedence should be considered. If multiple routes share the same route URI, only the first route will be called.
Async Await
Although Express.js routes are asynchronous by design, the framework natively works on callbacks. To handle promise-based APIs with async await, you need to prefix the callback function with the async
keyword.
app.get('/', async (req, res) => {
// create async operation
const promise = Promise.resolve('Hello Async Await!');
// resolve it
const rawString = await promise;
res.send(rawString); // Hello Async Await!
});H
The Request
The request object is responsible for reading incoming data from the client and sending it for further processing. The object comes packed with methods containing information about the request, such as:
- Parameters
- Query
- Headers
- Cookies (requires cookie-parser middleware)
- Body (requires body-parser middleware for versions < 4.13)
Dynamic routes
Any route you create can have a dynamic set of parameters. These can be manifested as route parameters or the request query.
- Use of the request parameters
app.get('/:id', (req, res) => {
const id = req.params.id;
res.send(id); // 123
});
Example request:
> curl http://localhost:3000/123
These are useful when you want to retrieve or delete a particular entity (object) from the list, say a user with a given user ID.
- Use of the request query
app.get('/', (req, res) => {
const query = req.query;
res.send(query);// {"name":"'Mirza'"}
});
Example request:
> curl http://localhost:3000?name='Mirza'
The query is typically used when working with multiple dynamic parameters, such as search parameters or pagination.
Request Body
The request route can also accept the request body. This is a JSON object that contains an almost infinite range of fields or arrays of fields. Typical use cases are POST and PUT requests.
To read the incoming request body, you need first to set up the request body parser middleware:
const express = require('express');
const app = express();
app.use(express.json()); // inject body parser middleware
Afterward, you’ll be able to read the request body:
app.post('/', (req, res) => {
const data = req.body; // read request body
res.send(data.username); // Mirzly
});
Example request:
> curl -X POST
-H "Content-Type: application/json"
--data '{"username":"Mirzly", "profession":"DEV"}'
http://localhost:3000
The Response
The response object is responsible for sending the appropriate response to the client after processing the request. The response can vary:
- Send different content types (plain text, HTML, JSON, binary)
- Use different status codes (200, 201, 204, 400, 404, 500, etc.)
- Set custom headers and cookies
- Create a redirect to a temporary route or a completely new domain
- Download the file to the client
- Render a template page
Response per Request
By default, one request can only have one response:
app.get('/', (req, res) => {
res.send('Hello World!');
});
However, when working with streams (or server-sent events), you can send the response in chunks and then send the final response when you’re done:
app.get('/', (req, res) => {
res.write('1');
res.write('2');
res.write('3');
res.end();
});
Note that the app will get the time-out exception if the response is not sent.
Content-Type
The default content type sent from the server is text/plain.
app.get('/', (req, res) => {
res.send('Hello World!'); // text
});
However, if you send the response as something different, like JSON, Express.js will automatically set the appropriate response headers:
app.get('/', (req, res) => {
res.send({ data: 'Hello World!' }); // JSON
});
This is one of the Express.js core features. Whereas in the core Node.js HTTP module, you’d need to manually set the correct MIME type for each response (via response headers):
- HTML (
Content-Type: text/HTML
) - JSON (
Content-Type: application/json
) - Binary (
Content-Type: application/octet-stream
),
Express.js takes care of this under the hood by exposing a simple method for you to use: — res.send()
.
Status Codes
You might have wondered what HTTP status code was sent to the client for all the responses so far.
- The default status code is 200 (OK).
- In case of an exception, the response status code will be 500 (Internal Server Exception).
Express.js allows you to send any status code you desire.
201 Created:
app.get('/', (req, res) => {
res.status(201).send('Entity created!');
});
400 Bad Request:
app.get('/', (req, res) => {
res.status(400).send('Check your data!');
});
401 Unauthorized Exception:
app.get('/', (req, res) => {
res.status(401).send('Sign-in required!');
});
404 Not Found Exception:
app.get('/', (req, res) => {
res.status(404).send('Data not found!');
});
500 Internal Server Exception:
app.get('/', (req, res) => {
res.status(500).send('Something went wrong!');
});
And so on.
The Middleware functions are a core feature of the Express.js router. The Middleware is a function that serves as a guard between the request and the response.
// Example middleware
app.use((req, res, next) => {
console.log(`${req.method} request for '${req.url}'`);
next();
});
// Route handler
app.get('/', (req, res) => {
res.send('Hello, world!');
});
Since middleware has access to the request and the response, it can read the incoming request information and decide whether to let the request proceed forward or send the error response to the client, e.g.
app.use((req, res, next) {
if (!req.header('access-token')) {
return res.status(401).send('Unauthorized!');
}
next(); // calling next() lets this request proceed
});
You’ll typically spot a middleware as a function injected with the use keyword:
app.use(SOME-FUNCTION);
Previously, when I talked about parsing the request body I used the built-in body-parser middleware:
const express = require('express');
const app = express();
app.use(express.json()); // middleware
Middleware can be used for things like:
- Logging
- Validating incoming request data
- Change the workflow of the application (activate Maintenance mode)
- Protecting routes (authentication, rate-limiters), etc.
Three types of middleware:
- Global middleware
- Route-based middleware
- Error handler middleware
Middleware functions are a big topic that is out of the scope of this introduction guide.
Connecting Frontend & Backend
This chapter is about connecting your web application to the backend server. This works with any web framework (such as Vue, Angular, React) or vanilla HTML JavaScript app that runs in the browser.
The backend API:
const express = require('express');
const app = express();
app.get('/hello', (req, res) => {
res.send('Hello World!');
});
app.listen(3000, () => {});
Frontend app:
Index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script src="./scripts.js"></script> <!-- injects script file -->
</head>
<body></body>
</html>
The default index.html file imports the scripts.js JavaScript file that invokes the backend server using the Fetch API:
const URI = 'http://localhost:3000/hello'
fetch(URI)
.then(res => res.text()) // because this endpoint returns plain text
.then(data => console.log(data))
.catch(err => console.error(err));
Double-click on the index.html file to open it in the browser. Once you do so, the HTML file will execute the JS script and invoke the backend API.
However, although the connection was made, there is a problem.
This exception has happened due to CORS and is expected.
The Cross-Origin-Request-Sharing (or CORS) defines a way for client web applications loaded in one domain to interact with resources in a different domain.
In this case, the web server (HTML site) attempts to talk to the backend server, which CORS does not prohibit. To get around this, you’ll need to whitelist your front-end application by setting up CORS on the backend. Stop the server and install CORS:
> npm i cors
Then, set up CORS in your app:
const express = require('express');
const app = express();
const cors = require('cors');
app.use(cors()); // injected CORS middleware
app.get('/hello', (req, res) => {
res.send('Hello World!');
});
Now rerun the server => node app.js
and make the same request from the front end.
The response should be 200 (OK), and the response message ‘Hello World!’ just as it was sent from the backend.
An environment variable is a user-definable value that can affect how running processes behave on a computer. In Node.js applications, you can use environment variables to store hardcoded strings and secrets.
The contents of this file should never be pushed to a public branch or any other publicly accessible environment.
You create an environment (.env) file in the root directory and define the variables.
PORT=9999
SECRET_KEY=123ABC
The variables are read using the process.env
object.
In newer versions of Node.js (2024 and above), this feature is available out of the box (explained here). However, for older versions, you can use the dotenv third-party package.
Using DotEnv
Here is how you set things up using dotenv.
- Install the package:
npm i dotenv
- Import the package to the top of the app.js file:
require('dotenv').config(); // <-- here it is
const express = require('express');
const app = express();
- Make use of the environment variables in the app:
require('dotenv').config();
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000; // 3000 being the default
const SECRET_KEY = process.env.SECRET_KEY;
app.get('/get-secret', (req, res) => {
res.send({ secret: SECRET_KEY });
});
app.listen(PORT, () => {
console.log(`Server started on port ${PORT}!`);
});
Now, if you rerun the server, you’ll notice something different:
> node app.js
Server started on port 9999!
The server is reading the port from the environment (.env) file.
Whereas before the server port was hardcoded (to 3000), now it is dynamic. This means the server can run on any port exposed by the underlying infrastructure.
Likewise, if you make the request to the localhost in the browser
(http://localhost:9999/get-secret
), you’ll get back the secret key from the environment file:
The Request:
> curl http://localhost:9999/get-secret
The Response:
{"secret":"123ABC"}
The same logic for setting and reading environment variables is used with database connections, authorization secrets, third-party API keys, etc.
So far, you have been running the server by retyping the same command (node app.js
) and manually restarting the server every time you made changes. In this chapter, that will be automated.
First, install the package called Nodemon as a developer dependency:
npm i --save-dev
Then, update the package.json file. In the scripts
section, replace the default script:
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
With this:
"scripts": {
"start": "node app.js",
"dev": "nodemon app.js"
},
Now, instead of running node.app js
, you can simply run
> npm start
Server started on port 9999!
This command is good for production builds. For development, you can use the dev
script to reload the server automatically anytime you hit the save button:
> npm run dev
> nodemon app.js
[nodemon] 2.0.15
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node app.js`
Server started on port 9999!
Note if you’re using a newer version of Node.js, you can also use the built-in watch mode.
"scripts": {
"start": "node app.js",
"dev": "node --watch app.js"
},
Wrapping up
This is a quick start to Express.js. In the future chapters we’ll dive deeper into Routing, Middleware functions, Static files, working with the database, and deployment.
More chapters:
Stay tuned!