Build a URL Shortener with Node.js

Build a REST API URL shortener with Express, SQLite, and a frontend.

Introduction

30 Second Summary

Every time you share a link on social media or in a text message, there's a good chance it was shortened by a service like bit.ly. These tiny URLs hide long, messy addresses behind short, memorable codes.

In this project, you will build your own URL shortener from scratch using Node.js and Express. You'll create a server that accepts any long URL, generates a unique short code, stores it in a database, and redirects visitors back to the original destination.

What You'll Build

Paste any long URL into your web form, get back a short link like http://localhost:3000/a8Xk9mPq, click it, and watch your browser land on the original page.

By the end of this project, you'll have:

  • A web form where you paste any URL and instantly get a clickable short link back.
  • Working HTTP redirects where clicking the short link sends your browser straight to the original destination.
  • A persistent SQLite database that keeps all your short links alive even after you restart the server.
  • Secret Mission: Add click tracking to count how many times each short link is visited.

Are there any prerequisites?

This project assumes basic familiarity with JavaScript (variables, functions, and objects). You do not need any prior experience with servers, databases, or backend development.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up the Project

Your URL shortener needs three packages before you can build anything. Express handles incoming web requests. nanoid generates unique short codes. better-sqlite3 stores your URLs in a SQLite database.

In this step, you'll get all three installed and create a minimal server to confirm everything works together.

In this step, get ready to:
  • Verify Xcode Command Line Tools are ready for native module compilation.
  • Create the project and install Express, nanoid, and better-sqlite3.
  • Run a minimal server and confirm it responds in the browser.
Check for Xcode Command Line Tools

The better-sqlite3 package is a native module. It may need to compile C++ code during installation, which requires Apple's Xcode Command Line Tools on macOS.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Check if Xcode Command Line Tools are installed by running this command:
xcode-select -p

✔️ I see a file path

If you see a path like /Library/Developer/CommandLineTools, you're all set. Xcode Command Line Tools are installed and ready.

ⓧ I see an error

If the command returns an error about no active developer directory, you need to install the tools.

  • Install Xcode Command Line Tools by running this command:
xcode-select --install

A dialog box will appear asking you to confirm the installation. Click Install and wait for it to complete. This may take a few minutes.

Create the project and install dependencies

Every Node.js project starts with a folder and a package.json file that lists its dependencies. You'll create both, then install the three packages your shortener needs.

  • 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
  • Confirm the folder was created by running:
pwd

You should see /Users/your-name/Desktop/url-shortener in the output.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the url-shortener folder, and click Open.
  • Open the integrated terminal by pressing Ctrl+` (macOS and Windows).

What is the integrated terminal?

VS Code has a built-in terminal panel at the bottom of the window. It opens directly inside your project folder, so you can run commands without switching between apps.

  • Create a new file by clicking the new file icon in the Explorer sidebar (or press Cmd+N on macOS, Ctrl+N on Windows).
  • Name the file package.json and paste the following content into it:
{
  "name": "url-shortener",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "5.2.1",
    "better-sqlite3": "12.11.1",
    "nanoid": "5.1.16"
  }
}

What does this file do?

The package.json file is the manifest for your Node.js project. It tells npm which packages to install and how to run your app.

  • "type": "module" enables ES modules so you can use import instead of require().
  • The dependencies section lists the three packages with their exact versions.
  • The start script lets you run npm start as a shortcut for node server.js.
  • Save package.json with Cmd+S (macOS) or Ctrl+S (Windows).
  • Install all three packages by running this command in the integrated terminal:
npm install

You should see output showing packages being downloaded and installed. This may take 30-60 seconds while better-sqlite3 compiles its native code.

Seeing a compilation error?

If you see errors mentioning gyp or node-pre-gyp, your Xcode Command Line Tools may not be installed correctly.

  • Run xcode-select --install in your terminal and try npm install again after it completes.
  • Make sure you saved package.json before running the install command.

Help me troubleshoot my npm install error

✔️ Awesome, I've got everything!

Great. Double check you've saved package.json and that node_modules appeared in your Explorer sidebar.

ⓧ I'd like to double check the full code

Your package.json should look exactly like this:

{
  "name": "url-shortener",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "5.2.1",
    "better-sqlite3": "12.11.1",
    "nanoid": "5.1.16"
  }
}
Create and run the server

With your packages installed, you can now create a minimal Express server. This confirms Express is working and gives you a live endpoint to hit in the browser.

  • Create a new file in the root of your url-shortener folder called server.js.
  • Paste the following code into server.js:
// Import the Express framework
import express from 'express';

// Create an Express application and set the port
const app = express();
const PORT = 3000;

// Define a route for the homepage
app.get('/', (req, res) => {
  res.json({ message: 'URL Shortener is running' });
});

// Start the server and log the URL
app.listen(PORT, () => {
  console.log(`URL Shortener running at http://localhost:${PORT}`);
});

What does this code do?

This is the smallest possible Express server. It does three things:

  • import express from 'express' loads the Express package using ES module syntax.
  • app.get('/', ...) defines a route that responds to GET requests at the root URL with a JSON message.
  • app.listen(PORT, ...) starts the server on port 3000 and prints a confirmation to your terminal.
  • Save server.js with Cmd+S (macOS) or Ctrl+S (Windows).

Before you run the server, what do you think will happen when you visit http://localhost:3000 in your browser?

  • Start the server by running this command in your integrated terminal:
node server.js

You should see this output in your terminal:

URL Shortener running at http://localhost:3000
  • Open your browser and visit http://localhost:3000.

You should see a JSON response: {"message":"URL Shortener is running"}. Your server is alive and responding to requests.

Don't see the JSON response?

  • Make sure you saved server.js before running node server.js.
  • Check your terminal for error messages. A SyntaxError means something is mistyped in your code.
  • If you see a port-in-use error, another process is using port 3000. Close other terminal tabs or running servers and try again.

Help me debug my Express server

✔️ Awesome, I've got everything!

Great. Double check you've saved server.js and that you can see the JSON response in your browser.

ⓧ I'd like to double check the full code

Your server.js should look exactly like this:

import express from 'express';

const app = express();
const PORT = 3000;

app.get('/', (req, res) => {
  res.json({ message: 'URL Shortener is running' });
});

app.listen(PORT, () => {
  console.log(`URL Shortener running at http://localhost:${PORT}`);
});

Your development environment is locked and loaded. Next up, you'll see HTTP redirection in action by building your first redirect route.

Add Your First Redirect

Your server is up and running. Now it's time to build the feature that makes a URL shortener actually work.

The core mechanism behind every URL shortener is HTTP redirection. When someone visits a short link, the server tells the browser to go to a completely different URL.

In this step, get ready to:
  • Add a redirect route to your Express server.
  • Test HTTP redirection live in your browser.
Add a redirect route to server.js

Before you build the full shortening system, you need to see a redirect happen with your own eyes. A single test route is all it takes.

  • In VS Code, open server.js.
  • Find the app.listen block at the bottom of the file.
  • Add this new route directly above app.listen:
app.get('/test', (req, res) => {
  // Send the browser to a different URL
  res.redirect('https://www.google.com');
});

What does this code do?

This registers a new route that handles GET requests to /test.

When a browser hits that path, res.redirect() sends back an HTTP 302 response. This tells the browser: "Don't stay here. Go to this other URL instead." The browser follows that instruction automatically.

  • Save server.js.
Test the redirect in your browser

Your server needs to be restarted to pick up the new route. The file changed on disk, but the running process still has the old code in memory.

  • Switch back to your terminal.
  • Stop the running server by pressing Ctrl+C.
  • Start the server again by running:
node server.js

Before you visit the test route, think about what you expect to happen. If you go to http://localhost:3000/test, will you stay on that page or end up somewhere else?

  • Open your browser and visit http://localhost:3000/test.

Watch the URL bar. You typed localhost:3000/test, but you end up on google.com. That is exactly what your short URLs will do. Instead of redirecting to Google, they will redirect to whatever long URL the user originally shortened.

Why does this matter for the project?

Every URL shortener in the world does this same thing. When you click a bit.ly or tinyurl link, their server looks up the original URL and sends your browser a redirect response. The only difference is they look it up from a database instead of hardcoding it.

Not seeing the redirect?

  • Make sure you saved server.js before restarting the server. Unsaved changes stay in the editor only.
  • Check that you stopped the old server with Ctrl+C before starting a new one. If the old process is still running, the new one will fail with an "address already in use" error.
  • Confirm the URL is exactly http://localhost:3000/test with no trailing slash or typos.

Help me debug why my /test redirect route is not working in Express.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and your redirect is working.

ⓧ I'd like to double check the full code

Here is the full server.js file at this point:

import express from 'express';

const app = express();
const PORT = 3000;

app.get('/', (req, res) => {
  res.json({ message: 'URL Shortener is running' });
});

app.get('/test', (req, res) => {
  res.redirect('https://www.google.com');
});

app.listen(PORT, () => {
  console.log(`URL Shortener running at http://localhost:${PORT}`);
});

You just proved that a three-line route can send a browser anywhere on the internet. Next up, you'll build an endpoint that accepts any URL, generates a unique short code, and redirects dynamically instead of always going to Google.

Build the URL Shortening API

Your /test route proved that Express can redirect a browser from one URL to another. That is the core mechanism behind every URL shortener.

A hardcoded redirect to Google is not a URL shortener, though. You need an endpoint that accepts any URL, generates a unique short code, and stores the mapping so you can look it up later.

In this step, get ready to:
  • Import nanoid and set up in-memory storage for URL mappings.
  • Build a POST endpoint that creates short URLs and a GET route that redirects them.
  • Test the API with curl and confirm redirects work in the browser.
Add imports, middleware, and storage

Before you can build the API routes, you need three pieces of plumbing: a library to generate short codes, middleware to parse incoming JSON, and a place to store URL mappings.

  • In server.js, find the existing import at the top of the file:
import express from 'express';
  • Add this new import line directly below it:
import { nanoid } from 'nanoid';
  • Now find the line const PORT = 3000; and add these two lines below it:
app.use(express.json());

const urls = {};

What do these additions do?

  • nanoid is a library that generates random, URL-safe strings. You will use it to create unique short codes.
  • express.json() is middleware that parses incoming JSON request bodies. Without it, req.body would be undefined.
  • urls is a plain JavaScript object that stores short code to URL mappings in memory.
  • Save server.js.
  • Stop the running server with Ctrl+C in your terminal, then restart it by running:
node server.js
  • You should see URL Shortener running at http://localhost:3000 with no errors.

Seeing a syntax error?

  • Check that the nanoid import uses curly braces: import { nanoid } from 'nanoid'; (it is a named export, not a default export).
  • Make sure app.use(express.json()); appears after const app = express(); but before any route definitions.

Having trouble? Help me fix a syntax error when starting my Express server with nanoid.

Add the shortening and redirect routes

The URL shortener needs two routes working together. One accepts a long URL and returns a short code. The other takes a short code and redirects to the original URL.

  • In server.js, find the /test route's closing }); and add the POST /shorten endpoint below it:
// Accept a URL and return a shortened version
app.post('/shorten', (req, res) => {
  const { url } = req.body;

  // Reject requests with no URL provided
  if (!url) {
    return res.status(400).json({ error: 'URL is required' });
  }

  // Generate a random 8-character code and store the mapping
  const shortCode = nanoid(8);
  urls[shortCode] = url;

  // Return the full short URL and the code
  res.json({
    shortUrl: `http://localhost:${PORT}/${shortCode}`,
    shortCode
  });
});

What does this route do?

  • It reads the url field from the JSON request body using destructuring.
  • If no URL was sent, it returns a 400 error with a helpful message.
  • nanoid(8) generates a random 8-character string using URL-safe characters (letters, digits, underscore, hyphen).
  • The mapping is stored in the urls object so the redirect route can find it later.
  • Now add the GET /:shortCode route directly below the /shorten endpoint's closing });:
// Look up a short code and redirect to the original URL
app.get('/:shortCode', (req, res) => {
  const { shortCode } = req.params;
  const originalUrl = urls[shortCode];

  // Return 404 if the short code doesn't exist
  if (!originalUrl) {
    return res.status(404).json({ error: 'Short URL not found' });
  }

  res.redirect(originalUrl);
});

What does this route do?

  • The colon in /:shortCode makes it a dynamic route parameter. Any path like /a8Xk9mPq will match this route.
  • It looks up the code in the urls object. If found, it redirects the browser to the original URL.
  • If the code does not exist, it returns a 404 JSON error so the user knows the link is invalid.
  • Save server.js.
  • Stop the server with Ctrl+C and restart it by running:
node server.js
  • Test the POST endpoint by running this curl command:
curl -X POST http://localhost:3000/shorten -H "Content-Type: application/json" -d '{"url":"https://www.example.com"}'
  • You should see a JSON response containing a shortUrl and a shortCode. The short URL will look something like http://localhost:3000/a8Xk9mPq (your code will be different since it is randomly generated).

Getting an empty response or an error?

  • If you see {"error":"URL is required"}, make sure app.use(express.json()) is above your route definitions. Without it, the server cannot read the JSON body.
  • If the command hangs or fails to connect, confirm the server is running (you should see the startup message in the other terminal).
  • On Windows, you may need to escape the JSON differently. Try: curl -X POST http://localhost:3000/shorten -H "Content-Type: application/json" -d "{\"url\":\"https://www.example.com\"}".

Still stuck? Help me debug my curl POST request to the /shorten endpoint.

✔️ Awesome, I've got everything!

Great. Double check you have saved server.js and the server is running with no errors.

ⓧ I'd like to double check the full code

Your server.js is over 30 lines now, so here is a checklist of what should be present from top to bottom:

  • Two import lines at the top: express and nanoid.
  • App creation (const app = express()) and PORT constant.
  • express.json() middleware and the urls object.
  • The root / route returning a JSON message.
  • The /test redirect route.
  • The POST /shorten route (generates a short code, stores mapping, returns JSON).
  • The GET /:shortCode route (looks up the code, redirects or 404s).
  • The app.listen call at the bottom.
Test the complete flow

Both routes are live. Time to prove the full loop works: shorten a URL, then visit the short link and watch the redirect happen.

Before you run the next command, predict: what do you think the JSON response will contain when you POST a GitHub URL to your shortener?

  • Shorten a GitHub URL by running this curl command:
curl -X POST http://localhost:3000/shorten -H "Content-Type: application/json" -d '{"url":"https://github.com"}'

You should see a JSON response with a shortUrl like http://localhost:3000/aBcDeFgH and a shortCode. The code is randomly generated, so yours will look different.

  • Copy the full shortUrl value from the response (including http://localhost:3000/).
  • Paste it into your browser's address bar and press Enter.

Your browser should redirect you to github.com. That is your URL shortener working end-to-end.

  • Try creating 2-3 more short URLs with different destinations by running:
curl -X POST http://localhost:3000/shorten -H "Content-Type: application/json" -d '{"url":"https://en.wikipedia.org"}'
  • Visit each returned short URL in your browser and confirm each one redirects to the correct destination.

Why test multiple URLs?

Each call to nanoid(8) generates a different random code. Testing several URLs confirms that your storage object holds multiple mappings at once and each code resolves independently.

Short URL returns a 404?

  • Make sure you did not restart the server between creating the short URL and visiting it. Restarting clears the in-memory urls object (you will fix this in the next step).
  • Double check that you copied the entire short URL including http://localhost:3000/ and the full code.

Need help? My short URL returns a 404 even though I just created it.

Your API is working. You can create short links and watch them redirect in real time. There is one problem, though: try stopping the server and starting it again, then visit one of your short URLs. In the next step, you will discover why that fails and fix it with a real database.

Add Database Persistence

Your URL shortener works. You can POST a long URL, get a short code back, and watch the redirect happen in the browser.

But there is a critical flaw hiding in your current setup. Let's expose it right now.

Before you run the next command, predict: what will happen if you stop the server and try one of your short URLs again?

  • Stop your running server by pressing Ctrl+C in the terminal.
  • Restart it by running:
node server.js
  • Try visiting one of the short URLs you created earlier in your browser.

You get a 404 error. Every short URL you created is gone.

Why did the data disappear?

Your short URLs were stored in a JavaScript object (const urls = {}). That object lives in your computer's memory only while the Node.js process is running.

The moment you stop the server with Ctrl+C, the process ends and everything in memory is erased. A real URL shortener needs storage that survives restarts.

In this step, get ready to:
  • Connect a SQLite database for persistent storage.
  • Replace in-memory storage with real database queries.
  • Verify your short URLs survive server restarts.
Add the database imports and setup

SQLite is a database that stores everything in a single file on your machine. The better-sqlite3 package gives you a fast, synchronous way to run SQL queries from Node.js.

  • In server.js, find the two import lines at the top of your file:
import express from 'express';
import { nanoid } from 'nanoid';
  • Add these three new imports directly below them:
// Database package for persistent storage
import Database from 'better-sqlite3';
// Helpers to resolve file paths in ES modules
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

What do these imports do?

  • Database is the SQLite library that reads and writes your database file.
  • fileURLToPath and dirname let you calculate the current directory in ES modules (__dirname is not available by default in ESM).
  • join builds file paths safely across operating systems. You will use it in the next step.
  • Now find this line:
const app = express();
  • Add these two lines directly above it:
// Derive __dirname for ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
  • Next, find the line that creates the in-memory storage object:
const urls = {};
  • Replace it with this database setup code:
// Create (or open) the SQLite database file
const db = new Database('urls.db');
// Enable WAL mode for better performance
db.pragma('journal_mode = WAL');
// Create the urls table if it doesn't already exist
db.exec(`
  CREATE TABLE IF NOT EXISTS urls (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    short_code TEXT UNIQUE NOT NULL,
    original_url TEXT NOT NULL,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP
  )
`);

What does this database setup do?

  • new Database('urls.db') creates a file called urls.db in your project folder. All data lives in this file.
  • WAL mode (Write-Ahead Logging) makes reads and writes faster by journaling changes before committing them.
  • The CREATE TABLE IF NOT EXISTS statement ensures the table is created on first run but skipped on subsequent starts.
  • Save server.js.
  • Restart the server by running:
node server.js
  • Confirm the server starts and prints URL Shortener running at http://localhost:3000.
  • Verify the database file was created by running this in a new terminal tab:
ls urls.db

You should see urls.db listed. The database is ready.

Server won't start?

  • If you see a syntax error, check that the backtick template literal around CREATE TABLE uses backticks (`), not regular quotes.
  • If you see a module import error, confirm all three new import lines use the exact package names: better-sqlite3, node:url, node:path.

Help me debug my better-sqlite3 import error.

Replace in-memory storage with database queries

The database is connected, but your route handlers still use the old urls object. Time to swap them to real SQL queries.

  • In server.js, find your existing POST /shorten route handler:
app.post('/shorten', (req, res) => {
  const { url } = req.body;

  if (!url) {
    return res.status(400).json({ error: 'URL is required' });
  }

  const shortCode = nanoid(8);
  urls[shortCode] = url;

  res.json({
    shortUrl: `http://localhost:${PORT}/${shortCode}`,
    shortCode
  });
});
  • Replace the entire route handler with this version that writes to the database:
app.post('/shorten', (req, res) => {
  const { url } = req.body;

  if (!url) {
    return res.status(400).json({ error: 'URL is required' });
  }

  const shortCode = nanoid(8);
  // Insert the new short URL into the database
  const stmt = db.prepare('INSERT INTO urls (short_code, original_url) VALUES (?, ?)');
  stmt.run(shortCode, url);

  res.json({
    shortUrl: `http://localhost:${PORT}/${shortCode}`,
    shortCode
  });
});

What changed in this route?

Instead of urls[shortCode] = url, you now use db.prepare() to create a prepared SQL statement and .run() to execute it. The ? placeholders get replaced with your values safely, preventing SQL injection.

  • Now find your existing GET /:shortCode route handler:
app.get('/:shortCode', (req, res) => {
  const { shortCode } = req.params;
  const originalUrl = urls[shortCode];

  if (!originalUrl) {
    return res.status(404).json({ error: 'Short URL not found' });
  }

  res.redirect(originalUrl);
});
  • Replace it with this version that reads from the database:
app.get('/:shortCode', (req, res) => {
  const { shortCode } = req.params;
  // Look up the original URL in the database
  const stmt = db.prepare('SELECT original_url FROM urls WHERE short_code = ?');
  const row = stmt.get(shortCode);

  if (!row) {
    return res.status(404).json({ error: 'Short URL not found' });
  }

  res.redirect(row.original_url);
});

What changed in this route?

Instead of reading from the urls object, you query the database with .get() which returns a single row (or undefined if no match). The redirect now uses row.original_url because the database returns an object with column names as keys.

  • Save server.js and restart the server.
  • Test the database-backed shortener by running:
curl -X POST http://localhost:3000/shorten -H "Content-Type: application/json" -d '{"url":"https://github.com"}'

You should get back a JSON response with a shortUrl and shortCode. Copy the short URL and visit it in your browser to confirm the redirect works.

Seeing a database error?

  • If you see an error about urls not being defined, make sure you replaced both route handlers completely. The old urls[shortCode] references should be gone.
  • If you see a constraint error, the database is working but had a collision. Try the curl command again to generate a new code.

Help me fix my database query error in the shorten endpoint.

Clean up old code and test persistence

Your database queries are working, but there is leftover code from the in-memory approach that should be removed.

  • In server.js, find and delete the const urls = {}; line. It is no longer used.
  • Find and delete the root route:
app.get('/', (req, res) => {
  res.json({ message: 'URL Shortener is running' });
});
  • Find and delete the test route:
app.get('/test', (req, res) => {
  res.redirect('https://www.google.com');
});
  • Save server.js.

Why remove the root and test routes?

The /test route was a learning exercise to see how redirects work. The / route was a placeholder. Neither is needed now that you have a real shortening API. You will add a proper homepage in the next step.

Now for the real test. Before you run the next commands, predict: will your short URL survive a server restart this time?

  • Create a short URL by running:
curl -X POST http://localhost:3000/shorten -H "Content-Type: application/json" -d '{"url":"https://github.com"}'
  • Copy the shortUrl from the response. You will need it after the restart.
  • Stop the server with Ctrl+C.
  • Restart it by running:
node server.js
  • Visit the same short URL in your browser.

It redirects successfully. Your data survived the restart because it is now stored in urls.db on disk, not in memory.

Short URL still returns 404 after restart?

  • Make sure you created the short URL AFTER switching to database queries. URLs created with the old in-memory code were never saved to the database.
  • Check that urls.db exists in your project folder by running ls urls.db. If it does not exist, the database setup code may not be running correctly.

Help me figure out why my short URLs are not persisting across server restarts.

✔️ Awesome, I've got everything!

Great. Confirm you have saved server.js and that your server restarts cleanly with no errors.

ⓧ I'd like to double check the full code

Your server.js should contain these sections in order. Scroll up and confirm each is present:

  • Five import lines at the top (express, nanoid, better-sqlite3, node:url, node:path).
  • __filename and __dirname derivation lines.
  • app and PORT setup, then express.json() middleware.
  • Database creation (new Database('urls.db')), WAL pragma, and CREATE TABLE statement.
  • POST /shorten route using db.prepare INSERT.
  • GET /:shortCode route using db.prepare SELECT.
  • app.listen at the bottom.
  • No const urls = {}, no /test route, no / route.

Your URL shortener now has a real database behind it. Short links survive restarts, crashes, and reboots. Next up, you will add a visual frontend so you can demo this to anyone without touching the terminal.

Add a Frontend Interface

Your URL shortener works, but right now the only way to use it is by typing curl commands in the terminal. That's not how a real user interacts with a product.

A visual frontend lets anyone paste a URL, click a button, and see the short link in seconds. In this step, you'll serve a web page from your Express server that talks directly to the /shorten API you already built.

In this step, get ready to:
  • Serve static files from a public folder using Express middleware.
  • Create an HTML page with a form that calls your shortening API.
  • Test the full browser-based flow from input to redirect.
Serve static files from a public folder

Express can serve static assets (HTML, CSS, images) from any folder you point it at. You just need to register the express.static middleware and tell it which directory to use.

  • In server.js, find the line that says:
app.use(express.json());
  • Add this new line directly below it:
app.use(express.static(join(__dirname, 'public')));

What does this line do?

express.static tells Express to look inside the public folder for any file matching the incoming URL. When someone visits /, Express automatically serves index.html from that folder without needing a dedicated route.

  • Save server.js.
  • Create the public folder by running this command in your terminal:
mkdir public
  • Confirm the folder was created by running:
ls

You should see public listed alongside server.js, node_modules, and urls.db.

Don't see the public folder?

Make sure your terminal is inside the url-shortener directory. Run pwd to confirm your current location.

Help me find my project folder

Create the HTML page with styles

Your frontend needs an HTML page with a URL input, a button, and a place to show results. You'll also add CSS to make it look clean and presentable.

  • Create a new file called index.html inside the public folder. In VS Code, right-click the public folder in the Explorer sidebar and select New File.
  • Name it index.html and press Enter.
  • Paste the following content to start the file with the HTML skeleton and page layout styles:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>URL Shortener</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      background: #f5f5f5;
      display: flex;
      justify-content: center;
      align-items: center;
      min-height: 100vh;
      padding: 20px;
    }
    .container {
      background: white;
      padding: 40px;
      border-radius: 12px;
      box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
      width: 100%;
      max-width: 500px;
    }
  • Continue by adding the form element styles directly below:
    h1 {
      margin-bottom: 8px;
      color: #333;
    }
    .subtitle {
      color: #666;
      margin-bottom: 24px;
    }
    .input-group {
      display: flex;
      gap: 8px;
      margin-bottom: 20px;
    }
    input {
      flex: 1;
      padding: 12px 16px;
      border: 2px solid #e0e0e0;
      border-radius: 8px;
      font-size: 16px;
      outline: none;
      transition: border-color 0.2s;
    }
    input:focus {
      border-color: #4a90d9;
    }
    button {
      padding: 12px 24px;
      background: #4a90d9;
      color: white;
      border: none;
      border-radius: 8px;
      font-size: 16px;
      cursor: pointer;
      transition: background 0.2s;
    }
  • Add the remaining styles, close the head, and add the HTML body structure:
    button:hover {
      background: #357abd;
    }
    .result {
      display: none;
      padding: 16px;
      background: #e8f5e9;
      border-radius: 8px;
      margin-top: 16px;
    }
    .result a {
      color: #2e7d32;
      font-weight: bold;
      word-break: break-all;
    }
    .error {
      display: none;
      padding: 16px;
      background: #ffebee;
      border-radius: 8px;
      margin-top: 16px;
      color: #c62828;
    }
  </style>
</head>
<body>
  <div class="container">
    <h1>URL Shortener</h1>
    <p class="subtitle">Paste a long URL and get a short link</p>
    <div class="input-group">
      <input type="url" id="urlInput" placeholder="https://example.com/very/long/url">
      <button id="shortenBtn">Shorten</button>
  • Finish the HTML body by adding the result and error containers, then close the page:
    </div>
    <div class="result" id="result">
      <span>Short URL: </span>
      <a id="shortUrl" href="#" target="_blank"></a>
    </div>
    <div class="error" id="error"></div>
  </div>
</body>
</html>

What does this HTML do?

The CSS centers a white card on a gray background with a clean, modern look. The .result and .error divs are hidden by default (display: none) until JavaScript shows them.

The form has a URL input and a Shorten button. When the short link comes back from the API, it will appear as a clickable link inside the .result div.

  • Save public/index.html.
  • Switch to your terminal and restart the server by pressing Ctrl+C to stop it, then running:
node server.js
  • Open http://localhost:3000 in your browser.

You should see a white card with a "URL Shortener" heading, a text input, and a blue Shorten button. Clicking the button does nothing yet because there's no JavaScript connected.

Seeing a blank page or JSON?

  • If you see a blank page, open DevTools (Cmd+Option+I on macOS, Ctrl+Shift+I on Windows) and check the Console tab for errors. A syntax error in your HTML will prevent the page from rendering.
  • Make sure your file is named exactly index.html (not Index.html or index.htm) and is directly inside the public folder.
  • Confirm that app.use(express.static(join(__dirname, 'public'))); is in your server.js and the server was restarted after adding it.

Help me debug why my HTML page isn't showing up

Connect the form to your API with JavaScript

The form looks good but it needs JavaScript to actually call your /shorten endpoint. You'll add a script that listens for the button click, sends the URL to your API, and displays the short link.

  • In public/index.html, find the closing </div> and </body> tags at the bottom of the file. Remove the </body> and </html> closing tags, then paste the following script and closing tags in their place:
  <script>
    const urlInput = document.getElementById('urlInput');
    const shortenBtn = document.getElementById('shortenBtn');
    const result = document.getElementById('result');
    const shortUrl = document.getElementById('shortUrl');
    const error = document.getElementById('error');

    shortenBtn.addEventListener('click', async () => {
      const url = urlInput.value.trim();

      if (!url) {
        showError('Please enter a URL');
        return;
      }

      try {
        const response = await fetch('/shorten', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ url })
        });

        const data = await response.json();

        if (!response.ok) {
          showError(data.error || 'Something went wrong');
          return;
        }
  • Add the response handling and the helper function directly below to complete the script:
        shortUrl.href = data.shortUrl;
        shortUrl.textContent = data.shortUrl;
        result.style.display = 'block';
        error.style.display = 'none';
      } catch (err) {
        showError('Failed to connect to server');
      }
    });

    function showError(message) {
      error.textContent = message;
      error.style.display = 'block';
      result.style.display = 'none';
    }
  </script>
</body>
</html>

What does this JavaScript do?

  • It grabs references to the input, button, and display elements using getElementById.
  • When the button is clicked, it sends a POST request to /shorten with the URL from the input field.
  • On success, it shows the returned short URL as a clickable link. On failure, it shows the error message in a red box.
  • The showError helper hides the result and displays the error text.
  • Save public/index.html.
  • Restart the server by pressing Ctrl+C in the terminal, then running:
node server.js

Before you test, predict what will happen when you paste a URL and click Shorten. Will you see the short link appear on the page?

  • Open http://localhost:3000 in your browser.
  • Paste https://github.com into the input field.
  • Click Shorten.

A green box appears with your short URL as a clickable link. Click it and your browser redirects to GitHub.

Button click does nothing?

  • Open DevTools (Cmd+Option+I on macOS, Ctrl+Shift+I on Windows) and check the Console tab for JavaScript errors.
  • Make sure the <script> tag is placed after the </div> that closes .container, not inside it.
  • Confirm the element IDs in your HTML (urlInput, shortenBtn, result, shortUrl, error) match the IDs in your JavaScript exactly.

Help me debug why my Shorten button isn't working

✔️ Awesome, I've got everything!

Great. Double check you've saved both server.js and public/index.html.

ⓧ I'd like to double check the full code

Your server.js should have just one new line compared to the previous step. Confirm this line appears right after app.use(express.json());:

app.use(express.static(join(__dirname, 'public')));

For public/index.html, confirm your file has these sections in order:

  • The HTML head with <style> containing CSS for *, body, .container, h1, .subtitle, .input-group, input, button, .result, and .error selectors.
  • The body with a .container div holding an h1, a .subtitle paragraph, a .input-group with input and button, a .result div, and an .error div.
  • A <script> block that grabs elements by ID, adds a click listener to shortenBtn, sends a POST to /shorten, displays the result, and defines a showError function.

Your URL shortener now has a proper interface. Anyone can open a browser, paste a link, and get a short URL without touching the terminal. Next up, you'll take on a Secret Mission to add click tracking and see how many times each short link gets visited.

Secret mission

Add Click Tracking

Your URL shortener works, but how do you know if anyone actually uses the links? Real URL shorteners track every click. In this mission, you'll add analytics that count how many times each short link is visited.

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, so there are no ongoing costs.

Resources you used:

  • url-shortener folder on your Desktop (contains server.js, public/index.html, package.json, node_modules, and the urls.db SQLite database).

Keep everything running

No action needed. Choose this if you are still actively building or want to extend the project further.

  • The urls.db file is tiny (a few kilobytes) and takes up no meaningful disk space.
  • Everything runs locally. There are no cloud services, subscriptions, or recurring charges.
  • You can restart the server any time by running node server.js from inside the url-shortener folder.

Pause - I'll come back to this later

Stop the running server to free up your terminal. Your project files and database stay on disk, ready for next time.

  • Press Ctrl+C in the terminal where your server is running to stop it.
  • All your short URLs are saved in urls.db and will still work when you start the server again.
  • To resume later, open a terminal, navigate to ~/Desktop/url-shortener, and run node server.js.

Delete - I don't want to use this again

Remove all project files, dependencies, and the database permanently. This cannot be undone.

  • Press Ctrl+C in the terminal to stop the server if it is still running.
  • Delete the entire project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/url-shortener

On Windows, run this instead:

rmdir /s /q %USERPROFILE%\Desktop\url-shortener
  • Confirm the folder is gone by running ls ~/Desktop (macOS/Linux) or dir %USERPROFILE%\Desktop (Windows). The url-shortener folder should no longer appear.

Nice Work!

Nice Work!

Nice work! You just built a fully functional URL shortener from scratch using Node.js, Express, and SQLite.

You've learned how to:

  • Build a REST API with Express that handles POST requests, dynamic route parameters, and HTTP redirects.
  • Use SQLite with better-sqlite3 for persistent data storage that survives server restarts.
  • Create a frontend interface that communicates with your API using fetch, and generate unique short codes with nanoid.
  • Secret Mission: Added click tracking analytics to count how many times each short link is visited.

Ready to quiz yourself?