Build a URL Shortener with Express

Build and deploy a fullstack URL shortener with Express and a cloud SQL database.

Introduction

30 Second Summary

Every time you share a link, you are making a first impression. A clean, short URL looks professional. A sprawling 200-character URL with tracking parameters does not.

In this project, you will build and deploy your own URL shortener. You will paste in a long URL, get back a short code, and when anyone visits that code, they get redirected to the original page.

What You'll Build

You will have a live web app where you paste a long URL into a form, click shorten, and immediately get a short link you can send to anyone. When they click it, they land on the original page, and you can see how many times each link was used.

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

  • A working REST API that accepts any long URL and returns a unique short code you can share with anyone.
  • A click tracker that counts how many times each short link has been visited, displayed on a simple dashboard.
  • A live deployed app on Vercel with a public URL you can put on your resume and share with employers.
  • Secret Mission: Add a custom alias feature so users can choose their own short codes instead of random ones.

Are there any prerequisites?

You should have basic knowledge of HTML, CSS, and JavaScript. You will also need Node.js installed on your machine. No prior experience with databases or deployment is required.

Before We Start

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

Set Up Your Project and Accounts

Your URL shortener needs three things before you can start building: a project folder with the right packages, a cloud database to store URL mappings, and a hosting account to deploy the finished app.

In this step, you will create the project structure, install your dependencies, and set up free accounts on Turso (your database) and Vercel (your deployment platform).

In this step, get ready to:
  • Initialize your project folder and install dependencies.
  • Create a Turso account and cloud database.
  • Create a Vercel account for deployment.
Initialize your project and install dependencies

Every Node.js project starts with a folder and a package.json file that tracks its dependencies. You will create a project folder, initialize it, and install the four packages your URL shortener needs.

  • Open your terminal.
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • 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 a new Node.js project by running:
npm init -y

You should see output showing a new package.json was created with default values.

  • Install the four dependencies your project needs by running:
npm install express @libsql/client nanoid@3 dotenv

What are these packages?

  • express is the web framework that handles HTTP requests and routes.
  • @libsql/client is the official Turso database client for connecting to your cloud SQLite database.
  • nanoid@3 generates unique, URL-safe short codes for each shortened link.
  • dotenv loads environment variables from a .env file so your credentials stay out of your code.

You should see output confirming that packages were added and a node_modules folder was created.

  • Confirm everything installed by running:
ls node_modules

You should see a list of folders including express, nanoid, and dotenv.

Command not found or install failed?

  • Make sure Node.js 18 or higher is installed. Run node --version to check.
  • If you see a permissions error on macOS, try prefixing with sudo or reinstall Node.js using a version manager.

Still stuck? Help me fix my npm install

Now you need to update your package.json with the correct project configuration. The defaults from npm init need adjusting so your app knows where the main entry file lives and has convenient start scripts.

  • Open your project in your code editor.
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code (or Cursor) and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the url-shortener folder on your Desktop, then click Open.
  • Open package.json from the file explorer sidebar.
  • Replace the entire contents of package.json with this:
{
  "name": "url-shortener",
  "version": "1.0.0",
  "description": "A URL shortener built with Express and Turso",
  "main": "api/index.js",
  "scripts": {
    "start": "node api/index.js",
    "dev": "node api/index.js"
  },
  "dependencies": {
    "@libsql/client": "^0.15.0",
    "dotenv": "^16.5.0",
    "express": "^5.2.1",
    "nanoid": "^3.3.11"
  }
}

What changed here?

  • main points to api/index.js because that is where your Express server will live.
  • The start and dev scripts let you launch the server with npm start or npm run dev.
  • The description gives your project a meaningful label.
  • Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Version numbers may differ slightly

Your package.json may show slightly different patch versions (e.g. ^3.3.15 instead of ^3.3.11 for nanoid). That is normal. The caret (^) means npm installed the latest compatible version.

Create a Turso account and database

Your URL shortener needs a database to store the mapping between short codes and original URLs. Turso provides a cloud-hosted SQLite database that works perfectly with serverless deployments like Vercel.

Why Turso instead of a local SQLite file?

Vercel has an ephemeral filesystem. Any local file you create gets wiped between requests. Turso gives you a persistent cloud database with a SQLite-compatible interface, so your data survives deployments.

You will use the Turso CLI (command-line tool) to create your account and database. This is faster than the web dashboard and gives you the credentials you need in one place.

  • Install the Turso CLI by running this command in your terminal:

macOS

brew install tursodatabase/tap/turso

Homebrew not installed?

If you see a "command not found" error for brew, use the alternative install script instead:

curl -sSfL https://get.tur.so/install.sh | bash

Windows

curl -sSfL https://get.tur.so/install.sh | bash
  • Sign up for a Turso account by running:
turso auth signup

This opens your browser to create an account. Complete the sign-up process, then return to your terminal.

  • Create a new database called url-shortener by running:
turso db create url-shortener

This takes a few seconds. When it finishes, your cloud database is ready.

  • Get your database URL by running:
turso db show --url url-shortener

You should see a URL that looks like libsql://url-shortener-yourorg.turso.io. Copy this value.

  • Create an auth token for the database by running:
turso db tokens create url-shortener

The command prints a long token string. Copy this value as well.

Now you will save these credentials in a .env file so your app can connect to the database without hardcoding secrets.

  • In your code editor, create a new file called .env in the root of your url-shortener folder.
  • Paste the following into .env, replacing the placeholder values with the URL and token you just copied:
TURSO_DATABASE_URL=libsql://your-database-name-your-org.turso.io
TURSO_AUTH_TOKEN=your-auth-token-here

What do these variables do?

  • TURSO_DATABASE_URL tells your app where to find the database on the internet.
  • TURSO_AUTH_TOKEN proves your app has permission to read and write data.
  • Save .env by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Turso CLI not working?

  • If turso auth signup does not open your browser, try running turso auth login instead if you already have an account.
  • If you get a "command not found" error after installing, close and reopen your terminal so the PATH updates.

Still stuck? Help me set up Turso

Turso is free for this project

The Turso Starter plan includes 5 GB of storage and 500 million rows read per month with no credit card required. This project uses a tiny fraction of that.

Create a Vercel account

Vercel is where you will deploy your finished URL shortener so anyone on the internet can use it. You will connect it to GitHub so it can automatically deploy your code.

  • Open your browser and go to vercel.com/signup.
  • Click Continue with GitHub.
  • Authorize Vercel to access your GitHub account when prompted.
  • Complete the sign-up flow.

Once you see the Vercel dashboard, your account is ready. You will not need Vercel again until the final deployment step.

GitHub authorization not working?

If the GitHub authorization screen does not appear, make sure you are signed into GitHub in the same browser. Sign out of any other GitHub accounts first, then retry the Vercel sign-up.

Need more help? Help me connect Vercel to GitHub

Verify your setup

Before moving on, confirm that Express is correctly installed and your project can load it.

  • What do you think will happen when you try to load Express from your project folder? Will it succeed or fail?
  • Test that Express loads correctly by running this in your terminal (make sure you are in the url-shortener folder):
node -e "const e = require('express'); console.log('Express loaded')"

You should see Express loaded printed in your terminal. This confirms your dependencies are installed and working.

Seeing an error instead?

  • If you see Cannot find module 'express', you may not be in the url-shortener folder. Run cd ~/Desktop/url-shortener and try again.
  • If you see a syntax error, make sure you copied the command exactly, including the quotes around the JavaScript string.

Still stuck? Help me verify Express is installed

✔️ Awesome, I've got everything!

Great. Double check you have saved both package.json and .env in your editor.

ⓧ I'd like to double check the full code

Here is what each file should look like at this point. Your .env values will be unique to your Turso database.

{
  "name": "url-shortener",
  "version": "1.0.0",
  "description": "A URL shortener built with Express and Turso",
  "main": "api/index.js",
  "scripts": {
    "start": "node api/index.js",
    "dev": "node api/index.js"
  },
  "dependencies": {
    "@libsql/client": "^0.15.0",
    "dotenv": "^16.5.0",
    "express": "^5.2.1",
    "nanoid": "^3.3.11"
  }
}
TURSO_DATABASE_URL=libsql://your-database-name-your-org.turso.io
TURSO_AUTH_TOKEN=your-auth-token-here

Your project foundation is solid. You have a package file, dependencies installed, a cloud database ready to accept data, and a Vercel account waiting for your first deployment. Next up, you will build the Express server and create your first API endpoint that actually shortens URLs.

Build the Express Server with a Shorten Endpoint

Your project folder is set up with all the packages you need, and your Turso database is waiting for data. Now it's time to write the application code that makes your URL shortener work.

The core of any URL shortener is simple: accept a long URL, generate a unique short code, and store the pairing. In this step, you'll build an Express server that does exactly that, connecting it to your Turso database so every shortened URL is saved in the cloud.

In this step, get ready to:
  • Create an Express server connected to your Turso database.
  • Initialize a database table to store URL mappings.
  • Build a POST endpoint that shortens URLs and returns a short code.
Create the Express app and connect to Turso

Your Express server lives in an api folder. This structure is required for deploying to Vercel later.

  • Create the api folder inside your project by running this command:
mkdir api
  • Create the main server file by running:
touch api/index.js
  • Confirm both were created by running:
ls api

You should see index.js listed in the output.

  • Open api/index.js in your editor and paste the following code:
// Load environment variables from .env file
require("dotenv").config();
const express = require("express");
const path = require("path");
const { createClient } = require("@libsql/client");
const { nanoid } = require("nanoid");

// Create the Express app
const app = express();

// Parse JSON request bodies and serve static files
app.use(express.json());
app.use(express.static(path.join(__dirname, "..", "public")));

// Connect to the Turso database
const db = createClient({
  url: process.env.TURSO_DATABASE_URL,
  authToken: process.env.TURSO_AUTH_TOKEN,
});

// Start the server on port 3000
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

module.exports = app;

What does this code do?

This sets up the skeleton of your Express server with four key pieces:

  • dotenv loads your TURSO_DATABASE_URL and TURSO_AUTH_TOKEN from the .env file so your code can access them.
  • express.json() lets the server understand JSON request bodies (like the URL you'll send to shorten).
  • express.static() serves HTML files from a public folder, which you'll create in a later step.
  • createClient() connects to your Turso database using the credentials in your .env file.
  • Save api/index.js.
  • Start your server by running:
node api/index.js

You should see Server running on http://localhost:3000 printed in your terminal.

Don't see the server start?

  • If you see Cannot find module 'express', make sure you ran npm install in Step 1 from inside your project folder.
  • If you see a Turso connection error, double-check that your .env file has the correct TURSO_DATABASE_URL and TURSO_AUTH_TOKEN values from your Turso dashboard.

help me fix my Express server startup error

Add the database initialization function

Your Turso database is empty right now. You need a table to store the URL mappings before your shortener can save anything.

  • Stop the running server by pressing Ctrl+C in your terminal.
  • In api/index.js, find the line that says const PORT = process.env.PORT || 3000; and add the following code above it:
// Create the urls table if it doesn't exist yet
async function initDatabase() {
  await db.execute(`
    CREATE TABLE IF NOT EXISTS urls (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      original_url TEXT NOT NULL,
      short_code TEXT NOT NULL UNIQUE,
      clicks INTEGER DEFAULT 0,
      created_at TEXT DEFAULT (datetime('now'))
    )
  `);
}

// Run database initialization when the server starts
initDatabase();

What does this code do?

The initDatabase() function creates a urls table in your Turso database with five columns:

  • id auto-increments for each new entry.
  • original_url stores the long URL the user submitted.
  • short_code stores the unique code (marked UNIQUE so no two URLs share the same code).
  • clicks tracks how many times the short link has been visited.
  • created_at records when the URL was shortened.

CREATE TABLE IF NOT EXISTS means it only creates the table the first time. Restarting your server won't wipe your data.

  • Save api/index.js and restart the server by running:
node api/index.js

You should see Server running on http://localhost:3000 again with no errors. Behind the scenes, your urls table was just created in Turso.

Seeing a SQL error?

  • Make sure the backtick characters around the SQL string are correct. The db.execute() call uses template literals (backticks `), not single quotes.
  • Verify your Turso auth token hasn't expired. You can generate a new one from app.turso.tech if needed.

help me debug my Turso database initialization error

Add the POST /api/shorten route and test it

Now for the exciting part. This route is the core of your URL shortener: it accepts a long URL, generates an 8-character code using nanoid, and stores the pairing in your database.

  • Stop the server with Ctrl+C.
  • In api/index.js, find the line const PORT = process.env.PORT || 3000; and add the following code above it (below the initDatabase(); call):
// Accept a URL and return a shortened version
app.post("/api/shorten", async (req, res) => {
  const { url } = req.body;

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

  // Validate that the URL is properly formatted
  try {
    new URL(url);
  } catch {
    return res.status(400).json({ error: "Invalid URL format" });
  }

  const shortCode = nanoid(8); // Generate a unique 8-character code
  try {
    await db.execute({
      sql: "INSERT INTO urls (original_url, short_code) VALUES (?, ?)",
      args: [url, shortCode],
    });
    res.status(201).json({
      shortCode,
      shortUrl: `${req.protocol}://${req.get("host")}/${shortCode}`,
      originalUrl: url,
    });
  } catch (error) {
    res.status(500).json({ error: "Failed to shorten URL" });
  }
});

What does this route do?

When someone sends a POST request to /api/shorten with a JSON body containing a url field, the route:

  • Validates the URL exists and is properly formatted (using the built-in URL constructor to check the format).
  • Generates an 8-character random code with nanoid(8). This uses URL-safe characters (letters, numbers, hyphens, underscores).
  • Inserts the original URL and short code into the urls table using parameterized queries (the ? placeholders prevent SQL injection).
  • Returns the short code, a full short URL, and the original URL in the response.
  • Save api/index.js and restart the server:
node api/index.js

Before you test this, what do you think will happen when you send a POST request with a URL to your new endpoint?

  • Open a second terminal window and test your endpoint by running:
curl -X POST http://localhost:3000/api/shorten -H "Content-Type: application/json" -d "{\"url\":\"https://github.com\"}"

You should see a JSON response with a shortCode field, a shortUrl field, and the originalUrl you submitted.

What should I see?

Your response looks something like this (your short code will be different since it's randomly generated):

{
  "shortCode": "a1B2c3D4",
  "shortUrl": "http://localhost:3000/a1B2c3D4",
  "originalUrl": "https://github.com"
}

Getting an error from curl?

  • On Windows, you may need to use PowerShell's Invoke-WebRequest or install curl separately. Alternatively, open http://localhost:3000 in a browser and use the developer tools console to run a fetch request.
  • If you see Connection refused, make sure your server is still running in the first terminal (you should see the Server running message).
  • If you get a 500 error, check your Turso credentials in .env are correct.

help me troubleshoot my curl request to the shorten endpoint

✔️ Awesome, I've got everything!

Great. Double check you've saved api/index.js and your server is still running.

ⓧ I'd like to double check the full code

Your api/index.js should contain these sections in order. Scroll up and confirm each one is present:

  • The imports and app setup (require("dotenv"), express, path, @libsql/client, nanoid).
  • The middleware lines (express.json() and express.static()).
  • The Turso createClient() connection.
  • The initDatabase() function and its call.
  • The app.post("/api/shorten", ...) route.
  • The PORT, app.listen(), and module.exports at the bottom.

Your Express server is running and your first API endpoint works. Next up, you'll add the redirect route that makes the short codes actually useful. When someone visits a short code, they'll be instantly redirected to the original URL.

Add the Redirect Route

Your shorten endpoint stores URLs in the database and returns a short code. But right now, visiting that short code does nothing.

A short URL that does not redirect is useless. In this step, you'll add the route that makes your URL shortener actually work. When someone visits a short code, your server will look it up in the database, track the click, and send a 302 redirect to the original URL.

In this step, get ready to:
  • Add a GET route that redirects short codes to their original URLs.
  • Handle missing short codes with a 404 error response.
  • Test the full redirect flow end-to-end.
Add the GET redirect route

Every URL shortener follows the same pattern: a user visits a short code, the server looks it up, and responds with a redirect. Your route also needs to handle the case where a short code does not exist.

  • In api/index.js, find the line that says const PORT = process.env.PORT || 3000;.
  • Add the following route above that line:
// Look up a short code and redirect to the original URL
app.get("/:shortCode", async (req, res) => {
  const { shortCode } = req.params;

  try {
    // Find the original URL in the database
    const result = await db.execute({
      sql: "SELECT original_url FROM urls WHERE short_code = ?",
      args: [shortCode],
    });

    // Return 404 if the short code doesn't exist
    if (result.rows.length === 0) {
      return res.status(404).json({ error: "Short URL not found" });
    }

    // Increment the click counter
    await db.execute({
      sql: "UPDATE urls SET clicks = clicks + 1 WHERE short_code = ?",
      args: [shortCode],
    });

    // Redirect the user to the original URL
    res.redirect(302, result.rows[0].original_url);
  } catch (error) {
    res.status(500).json({ error: "Server error" });
  }
});

What does this code do?

This route handles any request to /:shortCode (like /abc12345).

  • It queries the urls table for a matching short_code. If none exists, it returns a 404 JSON error so the visitor knows the link is invalid.
  • If the code is found, it runs an UPDATE query to increment the clicks column by 1. This tracks how many times each link has been used.
  • Finally, res.redirect(302, ...) sends a 302 temporary redirect response. The browser automatically navigates to the original URL.

Why a 302 and not a 301?

A 301 redirect is permanent. Browsers cache it and never ask your server again, which means click tracking stops working. A 302 tells the browser this is temporary, so it checks with your server every time and the click counter stays accurate.

  • Save api/index.js.
  • Switch back to your terminal. Stop the running server by pressing Ctrl+C.
  • Restart the server by running:
node api/index.js

You should see Server running on http://localhost:3000 printed again.

Server won't start?

  • Check for syntax errors. A missing comma, bracket, or parenthesis in the new route will prevent startup. Look at the terminal error message for a line number.
  • Make sure you pasted the new route above the const PORT line, not inside another route's closing braces.

Ask for help: My Express server won't start after adding the redirect route. What should I check?

Test the redirect end-to-end

Your route is live. Time to prove it works by shortening a URL and then visiting the short code.

  • First, create a short URL to test with. Run this in a new terminal tab or window:
curl -X POST http://localhost:3000/api/shorten -H "Content-Type: application/json" -d "{\"url\":\"https://github.com\"}"

You should get back a JSON response with a shortCode field. Copy that short code value.

  • Now test what happens with a short code that does not exist. Run:
curl -i http://localhost:3000/does-not-exist

You should see a 404 response with the JSON body {"error":"Short URL not found"}.

What does the -i flag do?

The -i flag tells curl to show the response headers along with the body. This lets you confirm you are getting a 404 status code and not a 200.

Now for the real test. Before you try this, do you think visiting your real short code in the browser will show JSON or take you somewhere else?

  • Open your browser and visit http://localhost:3000/ followed by the short code you copied earlier.

Your browser should redirect you to https://github.com. The URL in the address bar changes to GitHub's homepage.

Not redirecting?

  • Make sure you are using the exact short code from the curl response. Short codes are case-sensitive.
  • Check that your server is still running. If you closed the terminal, restart it with node api/index.js.
  • If you see a 404 JSON response, the short code might not have been saved. Run the POST command again to create a fresh one.

Ask for help: My short URL returns a 404 instead of redirecting. How do I debug this?

✔️ Awesome, I've got everything!

Great. Double check you have saved api/index.js and your redirect is working.

ⓧ I'd like to double check the full code

Your api/index.js should have these sections in order:

  • The require imports at the top (dotenv, express, path, @libsql/client, nanoid).
  • The Express app setup with express.json() and express.static() middleware.
  • The Turso db client creation with createClient().
  • The initDatabase() function and its call.
  • The app.post("/api/shorten", ...) route from the previous step.
  • The new app.get("/:shortCode", ...) route you just added.
  • The app.listen() call and module.exports at the bottom.

Scroll through your file and confirm each section is present in this order.

Your URL shortener actually shortens URLs now. Visit a code, get redirected. Next up, you'll add a frontend page and a list endpoint so you can demo this without touching the terminal.

Add a Frontend and List All URLs

Your API works. You can shorten URLs and redirect visitors using curl commands in the terminal.

The problem is that you cannot demo a curl command in an interview. A simple HTML page turns this into a complete fullstack app you can show to anyone in a browser.

In this step, get ready to:
  • Create a static HTML page with a URL input form and a results list.
  • Add an API endpoint that returns all shortened URLs from the database.
  • Write frontend JavaScript that connects the form to your API.
Create the HTML page structure

Your Express server already has express.static configured to serve files from a public folder. You just need to create that folder and add an HTML file.

  • Create the public folder inside your url-shortener project by running this command:
mkdir public
  • Create the index.html file inside the public folder by running:
touch public/index.html
  • Confirm both exist by running:
ls public

You should see index.html in the output.

  • Open public/index.html in your editor and paste the following head section with 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: system-ui, sans-serif; max-width: 700px; margin: 0 auto; padding: 2rem; background: #f9fafb; }
    h1 { margin-bottom: 1.5rem; color: #111827; }
    .form-group { display: flex; gap: 0.5rem; margin-bottom: 2rem; }
    input { flex: 1; padding: 0.75rem; border: 1px solid #d1d5db; border-radius: 0.5rem; font-size: 1rem; }
    button { padding: 0.75rem 1.5rem; background: #2563eb; color: white; border: none; border-radius: 0.5rem; font-size: 1rem; cursor: pointer; }
    button:hover { background: #1d4ed8; }
    .result { background: #ecfdf5; border: 1px solid #6ee7b7; padding: 1rem; border-radius: 0.5rem; margin-bottom: 2rem; display: none; }
    .result a { color: #059669; font-weight: bold; }
    h2 { margin-bottom: 1rem; color: #374151; }
    .url-list { list-style: none; }
    .url-item { background: white; border: 1px solid #e5e7eb; padding: 1rem; border-radius: 0.5rem; margin-bottom: 0.75rem; }
    .url-item .short { color: #2563eb; font-weight: bold; text-decoration: none; }
    .url-item .original { color: #6b7280; font-size: 0.875rem; display: block; margin-top: 0.25rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .url-item .clicks { color: #6b7280; font-size: 0.875rem; margin-top: 0.25rem; }
    .error { background: #fef2f2; border: 1px solid #fca5a5; padding: 1rem; border-radius: 0.5rem; margin-bottom: 1rem; color: #991b1b; display: none; }
  </style>
</head>

What do these styles do?

Each CSS rule targets a specific UI element you are about to create. The .form-group uses flexbox to place the input and button side by side.

The .result and .error divs start hidden (display: none) and only appear when JavaScript toggles them.

  • Directly below the closing </head> tag, add the body HTML with the form and URL list elements:
<body>
  <h1>URL Shortener</h1>
  <div class="error" id="error"></div>
  <div class="form-group">
    <input type="url" id="urlInput" placeholder="Paste a long URL here..." required>
    <button id="shortenBtn">Shorten</button>
  </div>
  <div class="result" id="result">
    Short URL: <a id="shortLink" href="#" target="_blank"></a>
  </div>
  <h2>Your URLs</h2>
  <ul class="url-list" id="urlList"></ul>

  <script>
  </script>
</body>
</html>

What does this HTML do?

  • The error div is a hidden container that shows API error messages when something goes wrong.
  • The form-group holds the URL input and the Shorten button side by side.
  • The result div is hidden by default and displays the generated short URL after a successful submission.
  • The urlList is an empty list that JavaScript will populate with all your shortened URLs.
  • Save public/index.html.
  • Open http://localhost:3000 in your browser.

You should see the heading "URL Shortener", an input field, and a blue Shorten button. The form does not work yet because the script tag is empty.

Seeing a blank page?

  • Make sure your server is still running. If you stopped it, run npm start again from the url-shortener folder.
  • Confirm the file is saved at public/index.html (not inside the api folder or at the project root).
  • Check for typos in the HTML. An unclosed tag can prevent the page from rendering.

Help me figure out why my HTML page isn't showing up at localhost:3000

Add the URL listing endpoint

Your frontend needs a way to fetch all shortened URLs from the database. You will add a GET /api/urls route that returns every row as JSON.

Why does route order matter?

Express matches routes top-to-bottom. Your existing GET /:shortCode route would catch a request to /api/urls (treating api as a short code) if it came first.

By placing /api/urls above /:shortCode, Express matches the specific path before the wildcard.

  • In api/index.js, find the closing }); of your app.post("/api/shorten", ...) route.
  • Add the following GET /api/urls route directly below it, above the existing app.get("/:shortCode", ...) route:
app.get("/api/urls", async (req, res) => {
  try {
    // Fetch all URLs, newest first
    const result = await db.execute(
      "SELECT id, original_url, short_code, clicks, created_at FROM urls ORDER BY created_at DESC"
    );
    res.json({ urls: result.rows });
  } catch (error) {
    res.status(500).json({ error: "Failed to fetch URLs" });
  }
});

What does this route do?

  • It selects every column from the urls table, ordered by created_at DESC so the newest links appear first.
  • It wraps the rows in a { urls: [...] } object so the frontend knows exactly where to find the array.
  • Save api/index.js.
  • Stop your running server with Ctrl+C, then restart it by running:
npm start
  • Test the new endpoint by running:
curl http://localhost:3000/api/urls

You should see a JSON response like {"urls":[...]} containing the URLs you shortened in the previous step.

Getting a 404 or empty response?

  • Make sure the app.get("/api/urls", ...) route is placed ABOVE app.get("/:shortCode", ...) in your file. If the wildcard route comes first, it intercepts the request.
  • Confirm you restarted the server after saving. Node.js does not hot-reload changes.

Help me debug why my /api/urls endpoint isn't returning data

✔️ Awesome, I've got everything!

Great. Double check you have saved api/index.js and that your server is running with the new route.

ⓧ I'd like to double check the full code

Your api/index.js file is over 30 lines, so confirm these sections are present in order:

  • Require statements and app setup (dotenv, express, path, createClient, nanoid).
  • Middleware: express.json() and express.static().
  • Turso client creation and initDatabase() function with the CREATE TABLE IF NOT EXISTS query.
  • POST /api/shorten route.
  • GET /api/urls route (the one you just added).
  • GET /:shortCode route (must be LAST among the routes).
  • Server listen and module.exports = app.
Write the frontend JavaScript

The HTML skeleton is in place and the API is ready. Now you will wire up the frontend so the form actually sends requests and displays results.

  • In public/index.html, find the empty <script> tag near the bottom of the file.
  • Inside the <script> tag, add these DOM reference variables:
    // Grab references to all interactive elements
    const urlInput = document.getElementById("urlInput");
    const shortenBtn = document.getElementById("shortenBtn");
    const resultDiv = document.getElementById("result");
    const shortLink = document.getElementById("shortLink");
    const urlList = document.getElementById("urlList");
    const errorDiv = document.getElementById("error");

What do these variables do?

Each const stores a reference to an HTML element by its id. This lets you read values, change text, and toggle visibility without searching the DOM every time.

  • Below the DOM references, add the shortenUrl function that sends the form data to your API:
    async function shortenUrl() {
      const url = urlInput.value.trim();
      if (!url) return;

      // Hide previous messages
      errorDiv.style.display = "none";
      resultDiv.style.display = "none";

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

        const data = await response.json();

        if (!response.ok) {
          errorDiv.textContent = data.error;
          errorDiv.style.display = "block";
          return;
        }

        // Show the new short URL
        shortLink.href = data.shortUrl;
        shortLink.textContent = data.shortUrl;
        resultDiv.style.display = "block";
        urlInput.value = "";
        loadUrls();
      } catch (err) {
        errorDiv.textContent = "Something went wrong. Please try again.";
        errorDiv.style.display = "block";
      }
    }

What does shortenUrl do?

  • It reads the URL from the input, sends a POST request to /api/shorten, and waits for the JSON response.
  • If the API returns an error, it displays the message in the red error div.
  • On success, it shows a clickable link to the newly created short URL, clears the input, and refreshes the URL list.
  • Save public/index.html and refresh your browser at http://localhost:3000.
  • Paste https://github.com into the input and click Shorten.

You should see a green box appear with your short URL. The URL list below is still empty because the loadUrls function does not exist yet.

  • Below the shortenUrl function, add the loadUrls function and the event listeners:
    async function loadUrls() {
      try {
        const response = await fetch("/api/urls");
        const data = await response.json();

        urlList.innerHTML = data.urls
          .map(
            (item) => `
          <li class="url-item">
            <a class="short" href="/${item.short_code}" target="_blank">${window.location.origin}/${item.short_code}</a>
            <span class="original">${item.original_url}</span>
            <span class="clicks">${item.clicks} click${item.clicks !== 1 ? "s" : ""}</span>
          </li>
        `
          )
          .join("");
      } catch (err) {
        urlList.innerHTML = "<li>Failed to load URLs.</li>";
      }
    }

    // Wire up button click and Enter key
    shortenBtn.addEventListener("click", shortenUrl);
    urlInput.addEventListener("keypress", (e) => {
      if (e.key === "Enter") shortenUrl();
    });

    // Load existing URLs when page opens
    loadUrls();

What does this code do?

  • The loadUrls function fetches all URLs from GET /api/urls and builds an HTML list showing each short link, original URL, and click count.
  • Two event listeners fire shortenUrl when the user clicks the button or presses Enter in the input.
  • The final loadUrls() call runs immediately so any previously shortened URLs appear on page load.
  • Save public/index.html.

Before you refresh the browser, what do you expect to see in the URL list section?

  • Refresh your browser at http://localhost:3000.

You should see the URL you just shortened appear in the list with its click count. Paste a new URL into the form, submit it, and the new short link appears immediately in the list below with "0 clicks".

URLs not showing in the list?

  • Open your browser's developer console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows). Look for red error messages that point to the problem.
  • If you see a fetch error for /api/urls, confirm your server was restarted after adding that route.
  • Check that loadUrls is called at the bottom of your script (outside any function).

Help me debug why my URL list is not loading on the page

✔️ Awesome, I've got everything!

Great. Confirm your public/index.html is saved and the page works end-to-end in the browser.

ⓧ I'd like to double check the full code

Your public/index.html is over 30 lines. Scroll through the file and confirm these sections are present in order:

  • DOCTYPE, opening <html>, and <head> with the full <style> block (all CSS selectors from * through .error).
  • The <body> with: h1, error div, form-group with input and button, result div with shortLink anchor, h2, and urlList.
  • The <script> tag containing: 6 const DOM references, shortenUrl() function, loadUrls() function, two event listeners, and a bare loadUrls() call.

Your URL shortener is now a complete fullstack app. Next up, you will deploy it to Vercel so anyone on the internet can use it.

Deploy to Vercel

Your URL shortener works perfectly on your machine. But a project that only runs on localhost cannot go on your resume or be shared with anyone.

In this step, you will push your code to GitHub and deploy it to Vercel. This gives you a live public URL that anyone can visit, proving you can build and ship real software.

In this step, get ready to:
  • Push your project code to a GitHub repository.
  • Deploy your app on Vercel with environment variables configured.
  • Test the live deployment end-to-end.
Create the deployment configuration files

Before pushing to GitHub, you need two files. A .gitignore keeps sensitive files out of the repository. A vercel.json tells Vercel how to run your Express app as a serverless function.

  • Create a .gitignore file in your url-shortener folder by running this command:
echo "node_modules\n.env" > .gitignore
  • Confirm the file was created by running:
cat .gitignore

You should see node_modules and .env listed on separate lines.

Why exclude these files?

The node_modules folder contains thousands of dependency files that anyone can regenerate by running npm install. The .env file contains your Turso database credentials. Pushing secrets to GitHub is a serious security risk.

  • Create a vercel.json file in the root of your url-shortener folder with this content:
{
  "version": 2,
  "builds": [
    {
      "src": "api/index.js",
      "use": "@vercel/node"
    }
  ],
  "routes": [
    {
      "src": "/(.*)",
      "dest": "api/index.js"
    }
  ]
}

What does this configuration do?

The builds array tells Vercel to run api/index.js using the @vercel/node runtime, which turns your Express app into a serverless function.

The routes array captures every incoming request (/(.*)) and sends it to your Express app. This means your Express routes handle everything, including static files and API endpoints.

  • Save vercel.json.
  • Verify both new files exist by running:
ls -a

You should see .gitignore and vercel.json alongside your existing api, public, and package.json files.

Don't see .gitignore in the listing?

Files starting with a dot are hidden by default. Make sure you used ls -a (with the -a flag) to show hidden files.

help me find my .gitignore file

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before continuing.

ⓧ I'd like to double check the full code

Your vercel.json should look exactly like this:

{
  "version": 2,
  "builds": [
    {
      "src": "api/index.js",
      "use": "@vercel/node"
    }
  ],
  "routes": [
    {
      "src": "/(.*)",
      "dest": "api/index.js"
    }
  ]
}

Your .gitignore should contain:

node_modules
.env
Push your code to GitHub

Vercel deploys directly from a GitHub repository. You need to create a repo, commit your code, and push it up.

  • Open GitHub in your browser and navigate to https://github.com/new to create a new repository.
  • Enter url-shortener as the repository name.
  • Leave the repository set to Public.
  • Do NOT check any of the initialization options (no README, no .gitignore, no license).
  • Click Create repository.

GitHub shows you a page with setup commands. You will use the commands for pushing an existing repository.

  • Back in your terminal, make sure you are inside your url-shortener folder.
  • Initialize a git repository and push your code by running these commands:
git init
git add .
git commit -m "Initial commit"
git branch -M main
git remote add origin [[REPO_URL="https://github.com/your-username/url-shortener.git"]]
git push -u origin main

What do these commands do?

  • git init creates a new git repository in your folder.
  • git add . stages all files (respecting .gitignore).
  • git commit saves a snapshot of your code.
  • git push uploads everything to GitHub.
  • Refresh your GitHub repository page in the browser.

You should see your project files listed: api/, public/, package.json, and vercel.json. You should NOT see node_modules or .env in the list.

Push failed or authentication error?

If git asks for credentials, you may need to set up a personal access token or SSH key. GitHub no longer accepts passwords for command-line operations. Visit your GitHub Settings, then Developer Settings, then Personal Access Tokens to generate one.

help me push my code to GitHub

Connect to Vercel and deploy

Your code is on GitHub. Now you will import it into Vercel and configure the database credentials so the deployed app can reach your Turso database.

  • Open the Vercel dashboard at https://vercel.com/dashboard.
  • Click the Add New... button and select Project.
  • Find your url-shortener repository in the list and click Import.

Vercel shows you a project configuration page. Before you deploy, you need to add your database credentials as environment variables.

  • Expand the Environment Variables section on the configuration page.
  • In the first Key field, enter TURSO_DATABASE_URL.
  • In the matching Value field, paste your Turso database URL (the one from your local .env file).
  • Click Add to save the first variable.
  • Add a second variable with the key TURSO_AUTH_TOKEN and paste your Turso auth token as the value.
  • Click Add to save the second variable.

Why add environment variables here?

Your .env file is excluded from GitHub (thanks to .gitignore), so Vercel cannot read it. Instead, Vercel provides its own environment variable system. Your code still reads process.env.TURSO_DATABASE_URL the same way, but the values come from Vercel's dashboard instead of a local file.

  • Click the Deploy button.

Vercel will build and deploy your project. This takes about 30-60 seconds. You will see a build log and then a success screen with confetti.

  • Once the deployment completes, click the preview image or the Visit button to open your live app.

You should see your URL Shortener page loading at a .vercel.app URL.

Deployment failed?

Check the build log for error messages. The most common issues are:

  • Missing environment variables. Double-check that both TURSO_DATABASE_URL and TURSO_AUTH_TOKEN are added correctly with no extra spaces.
  • Typo in vercel.json. Make sure "src": "api/index.js" matches your actual file path.

help me fix my Vercel deployment

Test the live deployment end-to-end

The deployment is live. Now you will prove the entire flow works on the public internet, not just on your machine.

  • Open your deployed Vercel URL in your browser (it looks like https://url-shortener-xyz.vercel.app).
  • Paste https://github.com into the URL input field.
  • Click Shorten.

You should see a green box appear with your new short URL.

  • Copy the short URL from the green result box.
  • Open a new browser tab and paste the short URL into the address bar.

Before you press Enter, what do you think will happen? Will the page show an error, or will you end up somewhere else?

  • Press Enter to visit the short URL.

Your browser redirects to https://github.com. The redirect worked on the live internet.

  • Switch back to your URL Shortener tab and refresh the page.

The link you just visited should now show 1 click in the list. Every time someone visits your short link, the count goes up.

Redirect not working?

If you see a JSON error instead of a redirect, check that your short code URL is correct. It should be your Vercel domain followed by the short code (e.g., https://url-shortener-xyz.vercel.app/aBcDeFgH).

If you get a generic server error, your environment variables might be misconfigured. Go back to your Vercel project settings and double-check both values.

help me debug my deployed URL shortener

Your URL shortener is live on the internet. Anyone with the link can shorten URLs and visit the short codes. This is a fully deployed fullstack application you can put on your resume and share with employers.

Secret mission

Custom Aliases

Right now your short codes are random strings like xK9mP2vL. Real URL shorteners like Bit.ly let users pick their own memorable codes. Can you add custom alias support with proper validation and conflict detection?

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. Both Turso and Vercel are on free tiers, so there are no ongoing costs for this project.

Resources you used:

  • Turso database (free Starter plan: 5 GB storage, 500 million rows read/month, no credit card required).
  • Vercel deployment (free Hobby plan: 4 hours Active CPU, 1 million invocations/month).
  • Local project folder (url-shortener on your Desktop).
  • GitHub repository for the URL shortener.

Keep everything running

No action needed. Choose this if you want to keep your URL shortener live and accessible.

  • Both Turso and Vercel are on generous free tiers. A personal project like this will not incur charges.
  • Your deployed URL shortener will remain accessible at your Vercel URL.
  • Your GitHub repo stays as a portfolio piece you can share with employers.

Pause - I'll come back to this later

Since both services are free tier, there is nothing to pause. Your deployment will stay live at no cost until you are ready to continue.

  • If you want to stop the app from being publicly accessible without deleting it, open your Vercel project, go to Settings in the sidebar, then scroll to Deployment Protection to restrict access.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow these steps to fully clean up.

Delete your Vercel project:

  • Open your Vercel dashboard and select your URL shortener project.
  • Click Settings in the sidebar.
  • Scroll to the bottom of the General page and click Delete in the Delete Project section.
  • In the dialog, enter your project name and the confirmation prompt, then click Continue.

Delete your Turso database:

  • Open app.turso.tech and sign in.
  • Select your URL shortener database from the databases list.
  • Open the database settings and click the delete option.
  • Confirm the deletion when prompted.

Delete your local project folder:

macOS

  • Delete the local folder by running:
cd ~/Desktop
rm -rf url-shortener

Windows

  • Delete the local folder by running:
cd ~\Desktop
rmdir /s /q url-shortener

Delete your GitHub repository (optional):

  • Open your repository on GitHub.
  • Click Settings at the top of the repository page.
  • Scroll to the bottom and click Delete this repository in the Danger Zone section.
  • Follow the confirmation prompts to complete the deletion.

That's a Wrap!

That's a Wrap!

Nice work! You just built and deployed a fully functional URL shortener from scratch.

You've learned how to:

  • Built a REST API with Express connected to a cloud SQLite database (Turso) using parameterized queries.
  • Implemented HTTP redirects (302) with click tracking that increments every time someone visits a short link.
  • Deployed a fullstack app to Vercel with environment variables and a live URL anyone can access.
  • Secret Mission: Added custom alias support with input validation and conflict detection.

Ready to quiz yourself?