Build a URL Shortener API with Express 5
Build a REST API that shortens URLs, redirects visitors, and tracks clicks.
Introduction
30 Second Summary
Every time you share a link, you're trusting the URL to be short enough to fit in a message and memorable enough to type. Services like bit.ly solve this by turning monster URLs into tidy short codes that redirect to the original page.
In this project, you will build a URL shortener REST API from scratch using Express 5 and Node.js. You'll create endpoints that shorten URLs, redirect visitors, and track click analytics, all tested from the terminal using curl.
What You'll Build
You paste a long URL into your API, get back a short code, share it with someone, and watch the redirect happen instantly while your server logs every request and counts each click.
By the end of this project, you'll have:
- A running URL shortener API where you paste a long URL, get back a short code, and visiting that code redirects to the original page automatically.
- A click tracking stats endpoint that counts how many times each shortened link has been visited.
- A custom middleware chain that logs every request with response timing, validates input before it reaches your routes, and returns clean JSON error responses instead of crashes.
- Secret Mission: build a rate limiting middleware from scratch that blocks abusive clients after too many requests.
Are there any prerequisites?
You should be comfortable with basic JavaScript (functions, objects, and async/await). You'll also need Node.js and VS Code already installed on your machine.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. Taking a moment to commit to the goal makes the hands-on work ahead more focused.
Set Up the Project
A professional Node.js project needs proper structure from the start. Real teams don't dump everything into a single file.
In this step, you'll create the project folder, install Express 5 and nanoid, then set up the modular folder structure that the rest of the project builds on.
In this step, get ready to:
- Initialize a Node.js project with Express 5 and nanoid installed.
- Create a modular folder structure with separate directories for routes and middleware.
- Wire up a minimal Express server that responds to a health check request.
Create the project directory and install dependencies
Every Node.js project starts with a folder, a package.json, and its dependencies installed. You'll create the project from scratch using npm.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (backtick key, same shortcut on both macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir url-shortener
cd url-shortener
- Initialize the project with npm by running:
npm init -y
You should see output confirming a package.json file was created with default values.
- Install Express and nanoid by running:
npm install express nanoid
You should see output listing express and nanoid as added packages.
Seeing a network error?
Make sure you have an active internet connection. If you see a permissions error, try running the command again without sudo. If the error persists, check that your Node.js installation is working by running node --version.
Help me troubleshoot my npm install error.
Now you need to open this folder in VS Code so you can see the file tree and edit files.
- Click File in the VS Code menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the url-shortener folder, and click Open.
VS Code now shows the url-shortener folder in the Explorer sidebar with node_modules, package.json, and package-lock.json listed.
Why open the folder this way?
Opening the folder (not just a file) tells VS Code to treat it as a workspace. The Explorer sidebar shows all files, the integrated terminal starts in the right directory, and features like search work across the whole project.
Next, you'll configure package.json to use modern JavaScript modules and add a start script.
- Click package.json in the Explorer sidebar to open it.
- Select all the contents and replace them with the following:
{
"name": "url-shortener",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node --env-file=.env server.js"
},
"dependencies": {
"express": "^5.2.1",
"nanoid": "^6.0.1"
}
}
What does this configuration do?
- "type": "module" tells Node.js to use ESM (import/export) syntax instead of the older require() style. This is the modern standard.
- The start script uses --env-file=.env to load environment variables from a .env file automatically. This is a built-in Node.js feature (no extra packages needed).
- The dependencies section locks the versions of Express and nanoid that npm already installed.
- Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Create the .env file by opening the integrated terminal (Ctrl+`) and running:
echo "PORT=3000" > .env
You should see .env appear in the Explorer sidebar.
What is a .env file?
A .env file stores configuration values (like port numbers and API URLs) outside your code. This keeps settings easy to change without editing source files. The --env-file flag loads these values into process.env at startup.
Create the project folder structure
Separating routes, middleware, and app configuration into their own files is how teams organize real codebases. You'll create this structure now so every file has a clear home.
- Create the directories and files by running these commands in the integrated terminal:
macOS
mkdir routes middleware
touch app.js server.js store.js
Windows
mkdir routes
mkdir middleware
New-Item app.js
New-Item server.js
New-Item store.js
- Confirm the files and folders exist by running:
ls
You should see app.js, middleware, node_modules, package-lock.json, package.json, routes, server.js, and store.js listed in the output.
Missing a file or folder?
Make sure your terminal is inside the url-shortener folder. Run pwd to check. If you see a different path, run cd ~/Desktop/url-shortener and try the creation commands again.
Help me fix my project folder structure.
Wire up the Express app and start the server
With the structure in place, you'll write two files. The first (app.js) defines the Express application. The second (server.js) starts it on a port.
- Click app.js in the Explorer sidebar to open it.
- Paste the following code into app.js:
// Import Express framework
import express from 'express';
// Create the app instance
const app = express();
// Parse JSON request bodies automatically
app.use(express.json());
// Health check endpoint - confirms the server is running
app.get('/', (req, res) => {
res.json({ status: 'ok', service: 'url-shortener' });
});
// Export the app so server.js can start it
export default app;
What does this code do?
- express() creates a new Express application instance that handles all incoming requests.
- express.json() is built-in middleware that automatically parses JSON request bodies. You'll need this when clients POST URLs to shorten.
- The GET / route acts as a health check. Hitting the root URL confirms the server is alive and returns a JSON response.
- export default app makes the app available to other files. This separation lets you test the app without starting the server.
- Save app.js.
- Click server.js in the Explorer sidebar to open it.
- Paste the following code into server.js:
// Import the Express app from app.js
import app from './app.js';
// Read port from environment variable, fallback to 3000
const PORT = process.env.PORT || 3000;
// Start listening for requests
app.listen(PORT, (error) => {
if (error) {
console.error('Failed to start server:', error.message);
process.exit(1);
}
console.log(`Server running on port ${PORT}`);
});
What does this code do?
- import app from './app.js' pulls in the Express app you just defined. This is why you exported it.
- process.env.PORT reads the port from the .env file you created. The || 3000 fallback means it still works even without the env file.
- app.listen() starts the server. In Express 5, the callback receives an error argument if the server fails to start (for example, the port is already in use).
- Save server.js.
Before you run this, what do you think you'll see printed in the terminal?
- Start the server by running:
node server.js
You should see Server running on port 3000 printed in the terminal. Your Express app is alive and listening for requests.
Seeing an error instead?
- If you see SyntaxError: Cannot use import statement, your package.json is missing "type": "module". Open it and make sure that line is present.
- If you see Error: Cannot find module, double check that app.js exists in the same folder as server.js. Run ls to confirm both files are listed.
- If you see EADDRINUSE, port 3000 is already taken by another process. Stop any other servers you have running, then try again.
Help me fix my Express server startup error.
✔️ Awesome, I've got everything!
Great. Make sure you've saved both app.js and server.js, and that you can see the server running message in your terminal.
ⓧ I'd like to double check the full code
Here is the full code for each file. Compare yours against these:
import express from 'express';
const app = express();
app.use(express.json());
app.get('/', (req, res) => {
res.json({ status: 'ok', service: 'url-shortener' });
});
export default app;
import app from './app.js';
const PORT = process.env.PORT || 3000;
app.listen(PORT, (error) => {
if (error) {
console.error('Failed to start server:', error.message);
process.exit(1);
}
console.log(`Server running on port ${PORT}`);
});
Your project structure is locked in and Express is responding. Next up, you'll build the core URL shortening endpoint that turns long URLs into short codes.
Build the Shorten Endpoint
Your server is alive and responding to health checks. Now it's time to give it its core feature: turning long URLs into short codes.
The heart of a URL shortener is a single endpoint. You send it a long URL, it generates a unique short code, stores the pair, and hands back the short version. In this step, you'll build this from the data layer up.
In this step, get ready to:
- Create an in-memory data store for URL records.
- Build a POST endpoint that generates short codes.
- Mount the new route and test it with curl.
Create the in-memory store module
Before building the endpoint, you need somewhere to store URL records. A JavaScript Map works perfectly for this. It lets you save and look up records by their short code instantly.
- Open store.js in VS Code and add the following code:
// In-memory storage using a Map (keyed by short code)
const links = new Map();
export function saveRecord(code, url) {
const record = {
code,
url,
clicks: 0,
createdAt: new Date().toISOString()
};
links.set(code, record);
return record;
}
export function getRecord(code) {
return links.get(code) || null;
}
export function incrementClicks(code) {
const record = links.get(code);
if (record) {
record.clicks += 1;
}
}
What does this code do?
This module wraps a Map with three exported functions that any other file can import:
- saveRecord(code, url) creates a record object with the code, original URL, a click counter starting at 0, and a timestamp. It stores the record in the Map and returns it.
- getRecord(code) looks up a record by its short code. Returns null if the code doesn't exist.
- incrementClicks(code) bumps the click counter by 1. You'll use this in the next step when you build the redirect endpoint.
- Save store.js.
- In your terminal, press Ctrl+C to stop the server from the previous step.
- Verify the store module loads correctly by running:
node --input-type=module -e "import { saveRecord } from './store.js'; console.log(saveRecord('test123', 'https://example.com'))"
You should see a record object printed with your code, URL, zero clicks, and a timestamp.
Seeing a syntax or import error?
- Make sure your package.json contains "type": "module". Without this, Node.js treats files as CommonJS and won't understand import/export syntax.
- Check that you saved store.js before running the command.
Help me debug an import error when running my store module.
✔️ Awesome, I've got everything!
Great. Make sure you've saved store.js before moving on.
ⓧ I'd like to double check the full code
// In-memory storage using a Map (keyed by short code)
const links = new Map();
export function saveRecord(code, url) {
const record = {
code,
url,
clicks: 0,
createdAt: new Date().toISOString()
};
links.set(code, record);
return record;
}
export function getRecord(code) {
return links.get(code) || null;
}
export function incrementClicks(code) {
const record = links.get(code);
if (record) {
record.clicks += 1;
}
}
Create the links router
Express Router lets you group related endpoints into their own module. Your links router will handle everything under /api/links.
- Open routes/links.js in VS Code and add the following code:
import { Router } from 'express';
import { nanoid } from 'nanoid';
import { saveRecord, getRecord } from '../store.js';
const router = Router();
// POST / - shorten a URL
router.post('/', (req, res) => {
// Generate an 8-character URL-safe code
const code = nanoid(8);
const record = saveRecord(code, req.body.url);
res.status(201).json({
shortUrl: `http://localhost:${process.env.PORT || 3000}/${code}`,
code: record.code,
url: record.url,
createdAt: record.createdAt
});
});
export default router;
What does this code do?
- Router() creates a mini-app that you'll mount at a specific path later.
- nanoid(8) generates an 8-character string using URL-safe characters (letters, digits, hyphens, underscores). Shorter codes are the whole point of a URL shortener.
- The route is just / because the full path (/api/links) gets defined when you mount the router in app.js.
- The response uses status 201 (Created) because a new resource was successfully made.
- Save routes/links.js.
✔️ Awesome, I've got everything!
Great. Make sure you've saved routes/links.js before moving on.
ⓧ I'd like to double check the full code
import { Router } from 'express';
import { nanoid } from 'nanoid';
import { saveRecord, getRecord } from '../store.js';
const router = Router();
// POST / - shorten a URL
router.post('/', (req, res) => {
// Generate an 8-character URL-safe code
const code = nanoid(8);
const record = saveRecord(code, req.body.url);
res.status(201).json({
shortUrl: `http://localhost:${process.env.PORT || 3000}/${code}`,
code: record.code,
url: record.url,
createdAt: record.createdAt
});
});
export default router;
Mount the router and test with curl
The router exists, but Express doesn't know about it yet. You need to import it in app.js and mount it at the /api/links path.
- In app.js, find this line at the top:
import express from 'express';
- Add this import directly below it:
import linksRouter from './routes/links.js';
- Now find the health check route:
app.get('/', (req, res) => {
res.json({ status: 'ok', service: 'url-shortener' });
});
- Add this line directly below the closing }); of the health check route:
app.use('/api/links', linksRouter);
What does app.use('/api/links', linksRouter) do?
This mounts the router at the /api/links prefix. Every route inside the router gets this prefix automatically. So router.post('/') becomes POST /api/links in the final API.
- Save app.js.
✔️ Awesome, I've got everything!
Great. Make sure you've saved app.js before moving on.
ⓧ I'd like to double check the full code
import express from 'express';
import linksRouter from './routes/links.js';
const app = express();
app.use(express.json());
app.get('/', (req, res) => {
res.json({ status: 'ok', service: 'url-shortener' });
});
app.use('/api/links', linksRouter);
export default app;
- Start your server by running:
npm start
You should see Server running on port 3000 in the terminal.
Before you test, think about this: what do you expect to get back when you POST a URL to your new endpoint? What HTTP status code should a successful creation return?
- Open a second terminal panel in VS Code (click the + icon in the terminal area) and test your endpoint by running:
curl -X POST http://localhost:3000/api/links -H "Content-Type: application/json" -d "{\"url\": \"https://expressjs.com/en/5x/api/\"}"
You should see a JSON response with a 201 Created status containing a shortUrl, your generated code, the original url, and a createdAt timestamp.
Getting an error or empty response?
- If you see Cannot find module, double-check that the import path in app.js is './routes/links.js' (with the .js extension). ESM requires file extensions in import paths.
- If you see Cannot POST /api/links, make sure you restarted the server after saving your changes.
- If the response body is empty, check that app.use(express.json()) is present in app.js before the router mount. Without it, Express can't parse the JSON request body.
Help me debug my POST /api/links endpoint.
Your URL shortener can now accept long URLs and generate short codes. Next up, you'll make those short codes actually work by building the redirect endpoint that sends visitors to the original URL.
Add Redirect and Click Tracking
Your API can shorten URLs, but those short codes don't actually go anywhere yet. If someone visits http://localhost:3000/abc123, they get nothing useful back.
The redirect endpoint is what transforms a short code into a real URL shortener. In this step, you'll add the route that catches short code visits and redirects to the original URL. You'll also add click tracking so you can prove the system works with hard numbers.
In this step, get ready to:
- Add a redirect endpoint that forwards visitors to the original URL.
- Add a stats endpoint that returns click count and link details.
- Test the complete shorten-redirect-track flow from the terminal.
Add the redirect route to app.js
When someone visits a short URL like /abc123, Express needs to catch that request, look up the code in the store, and send the visitor to the original URL with an HTTP redirect. This route must live in app.js above the /api/links mount so it catches short codes before the API router does.
- In app.js, find the import lines at the top of the file:
import express from 'express';
import linksRouter from './routes/links.js';
- Add this import line directly below them:
import { getRecord, incrementClicks } from './store.js';
- Now find the line that mounts the links router:
app.use('/api/links', linksRouter);
- Add the redirect route directly above that line by pasting this code:
app.get('/:code', (req, res, next) => {
const record = getRecord(req.params.code);
if (!record) {
const err = new Error('Short link not found');
err.status = 404;
return next(err);
}
incrementClicks(req.params.code);
res.redirect(302, record.url);
});
What does this route do?
- app.get('/:code', ...) matches any GET request with a single path segment, capturing it as req.params.code.
- getRecord(req.params.code) looks up that code in the in-memory store. If it returns null, the route passes a 404 error to the next middleware.
- incrementClicks() bumps the counter before redirecting so you can track how many times the link was used.
- res.redirect(302, record.url) sends back a 302 status with a Location header pointing to the original URL. The browser follows it automatically.
Why does this route go above the API mount?
Express evaluates routes in the order they are defined. The /:code pattern is very greedy. It matches any single-segment path. Placing it after the /api/links mount ensures API requests hit the router first, while short codes still get caught.
- Save app.js.
- Switch back to your terminal. Stop the running server by pressing Ctrl+C.
- Restart the server by running:
npm start
- First, create a short link to test with by running:
curl -X POST http://localhost:3000/api/links -H "Content-Type: application/json" -d "{\"url\": \"https://expressjs.com/en/5x/api/\"}"
You'll see a JSON response with a code field containing your generated short code. Copy that code for the next command.
- Test the redirect by running this command, replacing the short code with yours:
curl -v http://localhost:3000/[[SHORT_CODE="your-short-code"]]
You should see a 302 status code in the response and a location: https://expressjs.com/en/5x/api/ header pointing to the original URL.
Seeing a 404 or no redirect?
- Make sure you copied the exact short code from the POST response. It's case-sensitive.
- Check that the app.get('/:code', ...) route is placed above app.use('/api/links', linksRouter) in your file.
- Confirm you restarted the server after saving. In-memory data resets on restart, so the code from a previous session won't exist.
Still stuck? Ask for help.
Add the stats endpoint to the links router
Redirects prove the shortener works, but you also need a way to check analytics. A stats endpoint lets you query any short code and see its full record including the click count.
- In routes/links.js, find the line at the bottom that exports the router:
export default router;
- Add this stats endpoint directly above that export line:
router.get('/:code', (req, res, next) => {
const record = getRecord(req.params.code);
if (!record) {
const err = new Error('Link not found');
err.status = 404;
return next(err);
}
res.json(record);
});
What does this endpoint do?
This GET endpoint lives on the /api/links/:code path (because the router is mounted at /api/links). It returns the full record object as JSON, including code, url, clicks, and createdAt.
If the code doesn't exist, it creates a 404 error and passes it to next(err) for centralized handling later.
- Save routes/links.js.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on to testing.
ⓧ I'd like to double check the full code
Here's the full app.js at this point:
import express from 'express';
import linksRouter from './routes/links.js';
import { getRecord, incrementClicks } from './store.js';
const app = express();
app.use(express.json());
app.get('/', (req, res) => {
res.json({ status: 'ok', service: 'url-shortener' });
});
app.get('/:code', (req, res, next) => {
const record = getRecord(req.params.code);
if (!record) {
const err = new Error('Short link not found');
err.status = 404;
return next(err);
}
incrementClicks(req.params.code);
res.redirect(302, record.url);
});
app.use('/api/links', linksRouter);
export default app;
And the full routes/links.js:
import { Router } from 'express';
import { nanoid } from 'nanoid';
import { saveRecord, getRecord } from '../store.js';
const router = Router();
router.post('/', (req, res) => {
const code = nanoid(8);
const record = saveRecord(code, req.body.url);
res.status(201).json({
shortUrl: `http://localhost:${process.env.PORT || 3000}/${code}`,
code: record.code,
url: record.url,
createdAt: record.createdAt
});
});
router.get('/:code', (req, res, next) => {
const record = getRecord(req.params.code);
if (!record) {
const err = new Error('Link not found');
err.status = 404;
return next(err);
}
res.json(record);
});
export default router;
Test the full shorten-redirect-track flow
Time to run through the entire flow end to end. You'll shorten a URL, visit the short code, and then check the stats to confirm the click was tracked.
- Stop the running server with Ctrl+C and restart it by running:
npm start
- Shorten a URL by running:
curl -X POST http://localhost:3000/api/links -H "Content-Type: application/json" -d "{\"url\": \"https://expressjs.com/en/5x/api/\"}"
Copy the code value from the JSON response. You'll use it in the next two commands.
Before you run the next command, predict: what HTTP status code do you expect to see when you visit the short code? What header tells the browser where to go?
- Test the redirect by running:
curl -v http://localhost:3000/[[SHORT_CODE="your-short-code"]]
You'll see a 302 status code and a location header pointing to https://expressjs.com/en/5x/api/. That's the redirect in action.
Now predict: when you check the stats endpoint, what do you expect the clicks value to be?
- Check the stats for your short code by running:
curl http://localhost:3000/api/links/[[SHORT_CODE="your-short-code"]]
You should see a JSON response with "clicks": 1. Each time you visit the redirect URL, that number goes up.
Seeing clicks at 0?
- Make sure you visited the redirect URL (http://localhost:3000/your-code) before checking stats. The stats endpoint itself doesn't increment clicks.
- If you restarted the server between the redirect and the stats check, the in-memory data was wiped. Run all three commands (POST, redirect, stats) without restarting in between.
Need help debugging?
Your URL shortener now does the full job: shorten, redirect, and track. Next up, you'll add validation middleware so the API rejects bad input before it reaches your route handlers.
Add Validation Middleware
Your API shortens URLs, redirects visitors, and tracks clicks. There is a gap in the system though.
Right now, you can POST an empty body or a string like not-a-url and the API happily stores it. In production, invalid input causes silent data corruption. Custom validation middleware catches bad requests before they reach your route handlers.
In this step, get ready to:
- Expose the problem by posting garbage data to the shorten endpoint.
- Create validation middleware that catches invalid URLs.
- Apply the middleware and confirm it rejects bad input while accepting valid URLs.
Try posting invalid data
Before you fix anything, let's see the problem in action. What do you think happens when you post a string that is not a real URL?
- Make sure your server is still running in the terminal.
- Try posting garbage data by running this command:
curl -X POST http://localhost:3000/api/links -H "Content-Type: application/json" -d '{"url": "not-a-url"}'
You get back a 201 response with a short code. The API accepted not-a-url as a valid URL and stored it. That is the gap you are going to close.
Why does this matter?
If your redirect endpoint tries to send someone to not-a-url, the browser has nowhere to go. Validation middleware guards the boundary of your API so garbage never reaches your route logic.
Create the validation middleware
You will write a middleware function that checks two things: that the url field exists, and that it is a properly formatted URL.
- Create a new file called validateUrl.js inside the middleware/ folder.
- Add the following code:
export default function validateUrl(req, res, next) {
const { url } = req.body;
// Check that url exists and is a string
if (!url || typeof url !== 'string') {
const err = new Error('Missing required field: url');
err.status = 400;
return next(err);
}
// Check that url is a valid URL format
try {
new URL(url);
} catch {
const err = new Error('Invalid URL format');
err.status = 400;
return next(err);
}
next();
}
What does this code do?
- The first if block catches missing or non-string values. If someone posts an empty body or a number, this stops them.
- new URL(url) is a built-in JavaScript constructor that throws an error if the string is not a valid URL. The try/catch wraps it to catch that throw.
- next(err) passes the error to Express's error handling chain instead of sending a response directly. This keeps your middleware focused on one job: validation.
- next() at the end (no error argument) means the URL passed both checks, so the request continues to the next handler in the chain.
- Save middleware/validateUrl.js.
Apply the middleware and test
The middleware exists but is not connected to any route yet. You need to import it into the links router and slot it in front of the POST handler.
- In routes/links.js, find the import section at the top of the file:
import { Router } from 'express';
import { nanoid } from 'nanoid';
import { saveRecord, getRecord } from '../store.js';
- Add a new import for validateUrl between the nanoid and store imports, so the imports look like this:
import { Router } from 'express';
import { nanoid } from 'nanoid';
import validateUrl from '../middleware/validateUrl.js';
import { saveRecord, getRecord } from '../store.js';
- Now find the router.post line:
router.post('/', (req, res) => {
- Insert validateUrl as a route-specific middleware before the handler function, so the line now looks like this:
router.post('/', validateUrl, (req, res) => {
What is route-specific middleware?
When you pass multiple arguments to router.post(), Express runs them in order. The request hits validateUrl first. Only if it calls next() (no error) does the request continue to the handler that creates the short code.
- Save routes/links.js.
- Stop the running server by pressing Ctrl+C in the terminal.
- Restart the server by running:
npm start
Before you test, predict: will the API still accept not-a-url this time, or will it reject it?
- Open a second terminal tab and test with invalid data by running:
curl -X POST http://localhost:3000/api/links -H "Content-Type: application/json" -d '{"url": "not-a-url"}'
This time you get an error response containing Invalid URL format instead of a 201 with a short code. The validation middleware caught the bad input.
- Test with a missing url field by running:
curl -X POST http://localhost:3000/api/links -H "Content-Type: application/json" -d '{}'
You see an error response containing Missing required field: url. Both invalid and missing inputs are now rejected.
- Confirm valid URLs still work by running:
curl -X POST http://localhost:3000/api/links -H "Content-Type: application/json" -d '{"url": "https://expressjs.com/en/5x/api/"}'
You get back a 201 response with a short code and the original URL. Valid URLs pass right through the middleware to the handler.
Still seeing a 201 for invalid URLs?
- Make sure you saved both middleware/validateUrl.js and routes/links.js before restarting.
- Check that you added validateUrl as a second argument to router.post(), not inside the handler function.
- Confirm the import path is ../middleware/validateUrl.js (with the relative ../ prefix since you are inside routes/).
Help me debug why my validateUrl middleware is not rejecting invalid URLs.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
Here is the full middleware/validateUrl.js:
export default function validateUrl(req, res, next) {
const { url } = req.body;
if (!url || typeof url !== 'string') {
const err = new Error('Missing required field: url');
err.status = 400;
return next(err);
}
try {
new URL(url);
} catch {
const err = new Error('Invalid URL format');
err.status = 400;
return next(err);
}
next();
}
Here is the full routes/links.js:
import { Router } from 'express';
import { nanoid } from 'nanoid';
import validateUrl from '../middleware/validateUrl.js';
import { saveRecord, getRecord } from '../store.js';
const router = Router();
router.post('/', validateUrl, (req, res) => {
const code = nanoid(8);
const record = saveRecord(code, req.body.url);
res.status(201).json({
shortUrl: `http://localhost:${process.env.PORT || 3000}/${code}`,
code: record.code,
url: record.url,
createdAt: record.createdAt
});
});
router.get('/:code', (req, res, next) => {
const record = getRecord(req.params.code);
if (!record) {
const err = new Error('Link not found');
err.status = 404;
return next(err);
}
res.json(record);
});
export default router;
Your API now rejects garbage at the door. Next up, you will add request logging so you can see every request that hits the server, and centralized error handling so those error responses come back as clean, consistent JSON.
Add Request Logger and Error Handler
Your API now rejects invalid URLs before they reach the route handler. That's solid input defense.
Every production API needs to know what requests are hitting it. Right now, errors from next(err) hit Express's default handler which sends HTML instead of the consistent JSON that API clients expect.
In this step, get ready to:
- Create a request logger middleware that tracks method, path, status, and response time.
- Build a centralized error handler that returns consistent JSON for every error.
- Add a 404 catch-all for unmatched routes.
Create the request logger middleware
The logger needs to record when a request arrives, let the rest of the middleware chain run, then log the result when the response finishes. Node.js HTTP responses emit a finish event once data is fully sent to the client.
- Create middleware/logger.js with the following code:
export default function logger(req, res, next) {
// Record the time this request arrived
const start = Date.now();
// When the response finishes, log the details
res.on('finish', () => {
const duration = Date.now() - start;
console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`);
});
// Pass control to the next middleware immediately
next();
}
What does this code do?
- Date.now() captures the timestamp when the request first enters the middleware.
- res.on('finish', ...) registers a listener that fires after the response has been fully sent. At that point, the status code and timing are both available.
- next() is called immediately so the request continues through the middleware chain without waiting.
- Save middleware/logger.js.
Now mount the logger in app.js so it runs on every request.
- In app.js, add this import after the first line:
import logger from './middleware/logger.js';
- Find the line app.use(express.json());. Add this line directly below it:
app.use(logger);
Why mount the logger first?
Middleware executes in the order you mount it. Placing the logger right after express.json() means every request (including those that hit errors later) gets logged with its final status code and response time.
- Save app.js.
- Stop the running server by pressing Ctrl+C in the terminal.
- Restart the server by running:
npm start
- Test the logger by making a request:
curl http://localhost:3000/
In the terminal where your server is running, you should see a log line like GET / 200 2ms.
Not seeing a log line?
- Make sure you added app.use(logger) after app.use(express.json()) and not after your routes. Middleware loaded after a route won't run for requests that route already handled.
- Check that the import path is exactly './middleware/logger.js' with the .js extension. ESM requires full file extensions.
- Look at the terminal running the server, not the terminal where you ran curl. The log appears in the server's output.
Help me debug my logger middleware
Create the centralized error handler
A centralized error handler ensures every error returns the same JSON shape that API clients expect. Express recognizes error-handling middleware by its four-argument signature.
- Create middleware/errorHandler.js with the following code:
export default function errorHandler(err, req, res, next) {
// Default to 500 if no status was set on the error
const status = err.status || 500;
// Log the error for server-side visibility
console.error(`[ERROR] ${status} - ${err.message}`);
// Send a consistent JSON shape to the client
res.status(status).json({
error: err.message,
status
});
}
What does this code do?
- The four-argument signature (err, req, res, next) tells Express this is an error-handling middleware. All four parameters are required for Express to recognize it.
- err.status || 500 uses the status set by your validation or route handlers. If an unexpected error has no status, it defaults to 500.
- Every error response has the same two fields: error and status. Clients can rely on this shape for all errors.
- Save middleware/errorHandler.js.
Mount the error handler and test the complete flow
The error handler must be the very last middleware in your app. A 404 catch-all sits just before it, catching any request that didn't match a route and forwarding it as an error.
- In app.js, add this import at the top with the other imports (after the logger import):
import errorHandler from './middleware/errorHandler.js';
- Find the line app.use('/api/links', linksRouter);. Add the following code below it, before export default app;:
// Catch any request that didn't match a route
app.use((req, res, next) => {
const err = new Error('Not Found');
err.status = 404;
next(err);
});
// Send all errors as consistent JSON
app.use(errorHandler);
Why does order matter here?
Express processes middleware top to bottom. The 404 catch-all only runs if no route above it handled the request. The error handler sits last so it catches errors from anywhere in the chain.
Your validateUrl middleware and the /:code route both call next(err) when something goes wrong. Those errors now flow directly to this error handler instead of Express's default HTML response.
- Save app.js.
- Stop the server with Ctrl+C and restart it:
npm start
Before you test, what do you think will happen if you POST an invalid URL to the API? Will you get HTML or JSON back?
- Send a bad request to test the error handler:
curl -X POST http://localhost:3000/api/links -H "Content-Type: application/json" -d "{\"url\": \"not-a-url\"}"
You should see a clean JSON error response:
{"error":"Invalid URL format","status":400}
In the server terminal, you should also see both a log line from the logger and an error line from the error handler:
[ERROR] 400 - Invalid URL format
POST /api/links 400 3ms
- Confirm a valid request still works:
curl -X POST http://localhost:3000/api/links -H "Content-Type: application/json" -d "{\"url\": \"https://expressjs.com/en/5x/api/\"}"
You should see a 201 response with a short code, and the server terminal logs the request with a 201 status.
Getting HTML instead of JSON for errors?
- Make sure app.use(errorHandler) is the very last line before export default app;. If it's above a route, Express won't recognize it as the error handler for that route.
- Verify the function signature has exactly four parameters: (err, req, res, next). Express uses the argument count to distinguish error handlers from regular middleware.
- Check that the import path is './middleware/errorHandler.js' with the .js extension.
Help me debug my Express error handler
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files: middleware/logger.js, middleware/errorHandler.js, and app.js.
ⓧ I'd like to double check the full code
Here is middleware/logger.js in full:
export default function logger(req, res, next) {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`);
});
next();
}
Here is middleware/errorHandler.js in full:
export default function errorHandler(err, req, res, next) {
const status = err.status || 500;
console.error(`[ERROR] ${status} - ${err.message}`);
res.status(status).json({
error: err.message,
status
});
}
Your app.js should have these sections in order:
- Five imports at the top: express, logger, errorHandler, linksRouter, and the store functions.
- app.use(express.json()) followed by app.use(logger).
- The GET / health check route.
- The GET /:code redirect route.
- app.use('/api/links', linksRouter) router mount.
- The 404 catch-all middleware creating a 'Not Found' error.
- app.use(errorHandler) as the very last middleware.
- export default app at the bottom.
Your API now has full observability and consistent error responses. Every request is logged with timing data, and every error returns the same JSON shape. Next up, you'll extend this into a rate limiting challenge in the Secret Mission.
Secret mission
Build a Rate Limiter Middleware
In production, APIs get hammered by bots and abusive clients. Can you build a rate limiting middleware from scratch that blocks IPs sending too many requests within a time window?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
This project runs entirely locally and stores data in memory only. There are no ongoing costs.
Resources you used:
- The running Node.js server process on port 3000.
- The url-shortener project folder on your Desktop (includes all source files and node_modules).
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with the API.
- Leave the terminal open with node server.js running.
- The in-memory store resets every time you restart the server. No persistent data accumulates.
Pause - I'll come back to this later
Shut down the running server to free up the terminal. Your project files stay on disk ready for next time.
- Switch to the terminal where the server is running.
- Press Ctrl+C to stop the server.
- When you come back, navigate to your url-shortener folder and run npm start to pick up where you left off.
Delete - I don't want to use this again
Remove all project files and start fresh. Nothing persists outside the project folder.
- Switch to the terminal where the server is running and press Ctrl+C to stop it.
- Delete the project folder by running the command for your operating system.
macOS
- Run this command to delete the project folder:
cd ~/Desktop
rm -rf url-shortener
Windows
- Run this command to delete the project folder:
cd ~\Desktop
rmdir /s /q url-shortener
- Confirm the folder is gone by running ls (macOS) or dir (Windows) on your Desktop. You should no longer see url-shortener listed.
That's a Wrap!
That's a Wrap!
Nice work! You just built a production-style URL shortener REST API from scratch using Express 5 and Node.js 24. You can shorten URLs, redirect visitors, track clicks, and handle errors like a professional backend service.
You've learned how to:
- Build a complete REST API with Express 5 that shortens URLs, performs 302 redirects, and tracks click analytics through a stats endpoint.
- Organize a backend codebase with modular routing using Express Router and separate concerns into routes/, middleware/, and a store module.
- Write custom middleware for request logging with response timing, input validation that catches bad data before route handlers, and centralized error handling that returns consistent JSON error responses.
- Secret Mission: Built a rate limiting middleware from scratch using closures and time-window tracking to block abusive clients with a 429 response.
Ready to quiz yourself?