Build a URL Shortener with AI: From Zero to Deployed in One Session

This isn't a tutorial exercise — it's a real product. You'll build a working URL shortener that creates short links, stores them in a database, and redirects visitors. It's the kind of thing you can actually put on a custom domain and use every day. And you'll build the whole thing with AI in a single session.

TL;DR

You'll build a fully functional URL shortener — like a personal bit.ly. Someone pastes a long URL, gets back a short one. Anyone who visits the short URL gets redirected to the original. Stack: Express.js backend, nanoid for generating short codes, PostgreSQL or SQLite for storage, and a clean HTML frontend. This project teaches you CRUD operations, REST API design, database schemas, and deployment — all the foundational backend concepts packed into one build. Cost: free. Time: 2-3 hours.

Why AI Coders Need to Know This

If you've built a to-do app or a weather app, you've worked with frontend code and maybe called an external API. But you haven't built your own backend yet. A URL shortener changes that.

This single project teaches you more backend fundamentals than a dozen tutorials because it forces you to deal with every piece at once:

  • CRUD operations — Create a short URL (Create), look it up when someone visits (Read). That's the core of every web app, from Twitter to Shopify.
  • REST API design — You'll build real API endpoints. POST /shorten accepts a URL and returns a short code. GET /:code redirects to the original. These two routes teach you how every API on the internet works.
  • Database design — You need to store URL mappings somewhere permanent. You'll create a table, insert rows, and query them. This is the exact same skill you need for user accounts, product catalogs, or any app that remembers things.
  • Routing and redirects — When someone visits /abc123, your server has to figure out that's a short code (not a page), look it up, and send the browser somewhere else. Understanding HTTP redirects is crucial when your AI generates code that "works locally but breaks in production."
  • Deployment — A URL shortener is useless if it only runs on your laptop. You'll actually put this on the internet, which means dealing with hosting, databases, and environment variables for real.

Here's the best part: this is a product you can actually use. Hook it up to a custom domain and you've got your own branded link shortener. It's not a homework exercise — it's something you'd put on your portfolio and use daily.

Real Scenario: What Happens When You Ask AI to Build This

You open Claude (or Cursor, or Windsurf) and type:

Your Prompt

"Build me a URL shortener. I want to paste a long URL and get a short one back. When someone visits the short URL, they should be redirected to the original."

The AI will produce something that mostly works. It'll give you an Express.js server with a couple routes, some kind of short code generator, and maybe a basic HTML form. You'll paste it into a file, run node server.js, and... it'll probably work on the first try.

But here's what happens next: You share the short link with someone, and they get a 404. You restart your server and all your links are gone. Someone pastes javascript:alert('hacked') into the form and your app happily shortens it. You try to deploy it and the database connection string is hardcoded to localhost.

The AI got you 80% of the way there. The other 20% — the part that makes it a real product instead of a demo — is what this article teaches you. We'll start with what AI generates, understand every piece, fix what it gets wrong, and deploy it for real.

What AI Generated

Here's the prompt that gets the best first-pass output. It's specific enough that the AI won't make too many wrong assumptions:

Better Prompt

"Build a URL shortener with Node.js and Express. Requirements: (1) POST /api/shorten accepts a JSON body with { url: 'https://example.com/long-url' } and returns { shortUrl: 'http://localhost:3000/abc123', shortCode: 'abc123' }. (2) GET /:code looks up the short code in the database and redirects (301) to the original URL. If not found, return 404. (3) Use nanoid to generate 7-character short codes. (4) Use better-sqlite3 for the database. (5) Create the table automatically on startup if it doesn't exist. (6) Add a simple HTML frontend at GET / with a form to submit URLs and display the shortened result. (7) Include input validation — reject empty URLs and non-HTTP(S) URLs. (8) Add created_at timestamp and click_count to the database schema."

Here's the cleaned-up, annotated version of what your AI will produce:

Project Structure

url-shortener/
├── server.js          # The entire backend
├── public/
│   └── index.html     # The frontend form
├── database.sqlite    # Created automatically on first run
└── package.json       # Dependencies

package.json

{
  "name": "url-shortener",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "dev": "node --watch server.js"
  },
  "dependencies": {
    "express": "^4.21.0",
    "better-sqlite3": "^11.7.0",
    "nanoid": "^5.1.0"
  }
}

server.js — The Backend

import express from 'express'
import Database from 'better-sqlite3'
import { nanoid } from 'nanoid'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'

// ===== SETUP =====
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

const app = express()
const PORT = process.env.PORT || 3000
const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`

// Parse JSON request bodies
app.use(express.json())

// Serve the frontend
app.use(express.static(join(__dirname, 'public')))

// ===== DATABASE =====
const db = new Database('database.sqlite')

// Create the table if it doesn't exist
db.exec(`
  CREATE TABLE IF NOT EXISTS urls (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    original_url TEXT NOT NULL,
    short_code TEXT NOT NULL UNIQUE,
    click_count INTEGER DEFAULT 0,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  )
`)

// Prepare statements for performance
const insertUrl = db.prepare(
  'INSERT INTO urls (original_url, short_code) VALUES (?, ?)'
)
const findByCode = db.prepare(
  'SELECT * FROM urls WHERE short_code = ?'
)
const incrementClicks = db.prepare(
  'UPDATE urls SET click_count = click_count + 1 WHERE short_code = ?'
)

// ===== ROUTES =====

// POST /api/shorten — Create a short URL
app.post('/api/shorten', (req, res) => {
  const { url } = req.body

  // Validate the URL
  if (!url || typeof url !== 'string') {
    return res.status(400).json({ error: 'URL is required' })
  }

  // Check it's a valid HTTP(S) URL
  try {
    const parsed = new URL(url)
    if (!['http:', 'https:'].includes(parsed.protocol)) {
      return res.status(400).json({
        error: 'Only HTTP and HTTPS URLs are allowed'
      })
    }
  } catch {
    return res.status(400).json({ error: 'Invalid URL format' })
  }

  // Generate a unique short code
  const shortCode = nanoid(7)

  // Save to database
  try {
    insertUrl.run(url, shortCode)
  } catch (err) {
    // If there's a collision (extremely rare), try again
    const retryCode = nanoid(7)
    insertUrl.run(url, retryCode)
    return res.json({
      shortUrl: `${BASE_URL}/${retryCode}`,
      shortCode: retryCode
    })
  }

  res.json({
    shortUrl: `${BASE_URL}/${shortCode}`,
    shortCode: shortCode
  })
})

// GET /:code — Redirect to original URL
app.get('/:code', (req, res) => {
  const { code } = req.params

  const row = findByCode.get(code)

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

  // Increment the click counter
  incrementClicks.run(code)

  // Redirect to the original URL
  res.redirect(301, row.original_url)
})

// ===== START SERVER =====
app.listen(PORT, () => {
  console.log(`URL Shortener running at ${BASE_URL}`)
})

public/index.html — The Frontend

<!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: 'Segoe UI', system-ui, sans-serif;
      background: #0A0E1A;
      color: #E2E8F0;
      min-height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
    }

    .container {
      background: #1A1F2E;
      border-radius: 16px;
      padding: 2.5rem;
      width: 100%;
      max-width: 520px;
      box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
    }

    h1 {
      text-align: center;
      font-size: 1.75rem;
      margin-bottom: 0.5rem;
      color: #60A5FA;
    }

    .subtitle {
      text-align: center;
      color: #64748B;
      margin-bottom: 2rem;
      font-size: 0.95rem;
    }

    .form-group {
      display: flex;
      gap: 0.5rem;
      margin-bottom: 1.5rem;
    }

    input[type="url"] {
      flex: 1;
      padding: 0.85rem 1rem;
      border: 2px solid #2D3748;
      border-radius: 8px;
      background: #0A0E1A;
      color: #E2E8F0;
      font-size: 1rem;
      outline: none;
      transition: border-color 0.2s;
    }

    input[type="url"]:focus {
      border-color: #60A5FA;
    }

    button {
      padding: 0.85rem 1.5rem;
      background: #3B82F6;
      color: white;
      border: none;
      border-radius: 8px;
      font-size: 1rem;
      font-weight: 600;
      cursor: pointer;
      transition: background 0.2s;
      white-space: nowrap;
    }

    button:hover { background: #2563EB; }
    button:disabled { background: #1E3A5F; cursor: not-allowed; }

    .result {
      background: #0F1623;
      border: 1px solid #2D3748;
      border-radius: 8px;
      padding: 1.25rem;
      display: none;
    }

    .result.active { display: block; }

    .result-label {
      font-size: 0.8rem;
      color: #64748B;
      text-transform: uppercase;
      letter-spacing: 0.05em;
      margin-bottom: 0.5rem;
    }

    .result-url {
      display: flex;
      align-items: center;
      gap: 0.75rem;
    }

    .result-url a {
      color: #60A5FA;
      text-decoration: none;
      font-size: 1.1rem;
      font-weight: 600;
      word-break: break-all;
    }

    .result-url a:hover { text-decoration: underline; }

    .copy-btn {
      padding: 0.5rem 1rem;
      font-size: 0.85rem;
      background: #374151;
    }

    .copy-btn:hover { background: #4B5563; }

    .error-msg {
      color: #F87171;
      background: rgba(248, 113, 113, 0.1);
      padding: 1rem;
      border-radius: 8px;
      text-align: center;
      display: none;
    }

    .error-msg.active { display: block; }
  </style>
</head>
<body>
  <div class="container">
    <h1>🔗 URL Shortener</h1>
    <p class="subtitle">Paste a long URL and get a short one back</p>

    <div class="form-group">
      <input
        type="url"
        id="url-input"
        placeholder="https://example.com/very/long/url..."
        required
      >
      <button id="shorten-btn" onclick="shortenUrl()">Shorten</button>
    </div>

    <div class="error-msg" id="error"></div>

    <div class="result" id="result">
      <div class="result-label">Your shortened URL</div>
      <div class="result-url">
        <a id="short-url" href="#" target="_blank"></a>
        <button class="copy-btn" onclick="copyUrl()">Copy</button>
      </div>
    </div>
  </div>

  <script>
    async function shortenUrl() {
      const input = document.getElementById('url-input')
      const btn = document.getElementById('shorten-btn')
      const result = document.getElementById('result')
      const error = document.getElementById('error')
      const shortUrlEl = document.getElementById('short-url')

      const url = input.value.trim()
      if (!url) return

      // Reset UI
      error.classList.remove('active')
      result.classList.remove('active')
      btn.disabled = true
      btn.textContent = 'Shortening...'

      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) {
          throw new Error(data.error || 'Something went wrong')
        }

        shortUrlEl.href = data.shortUrl
        shortUrlEl.textContent = data.shortUrl
        result.classList.add('active')

      } catch (err) {
        error.textContent = err.message
        error.classList.add('active')
      } finally {
        btn.disabled = false
        btn.textContent = 'Shorten'
      }
    }

    function copyUrl() {
      const url = document.getElementById('short-url').textContent
      navigator.clipboard.writeText(url)
      const btn = event.target
      btn.textContent = 'Copied!'
      setTimeout(() => btn.textContent = 'Copy', 2000)
    }

    // Allow Enter key to submit
    document.getElementById('url-input')
      .addEventListener('keypress', (e) => {
        if (e.key === 'Enter') shortenUrl()
      })
  </script>
</body>
</html>

To run this locally:

# Create the project folder and install dependencies
mkdir url-shortener && cd url-shortener
npm init -y
npm install express better-sqlite3 nanoid

# Create the files (server.js and public/index.html) with the code above

# Start the server
node server.js

# Open http://localhost:3000 in your browser

Paste any URL into the form, click "Shorten," and you'll get a short link back. Click the short link and it'll redirect you to the original. That's a working URL shortener — built in about 15 minutes with AI.

Understanding Each Part

Let's break down every piece so you know what's happening — and more importantly, so you can tell your AI what to change when something doesn't work the way you want.

POST /api/shorten — Creating a Short URL

This is the Create in CRUD. When the frontend sends a URL, this route does three things:

  1. Validates the input — checks that you actually sent a URL, and that it starts with http:// or https://. Without this, someone could submit javascript:alert('hacked') and your shortener would happily redirect people to a malicious script.
  2. Generates a short codenanoid(7) creates a random 7-character string like V1StGXR. Seven characters using letters, numbers, hyphens, and underscores gives you over 3.5 trillion possible codes. You won't run out.
  3. Saves to the database — inserts the original URL and short code into the urls table. The UNIQUE constraint on short_code means the database will throw an error if there's a collision (two different URLs getting the same short code).
app.post('/api/shorten', (req, res) => {
  const { url } = req.body
  // req.body is the parsed JSON from the request
  // { url: "https://example.com/long-url" } → url = "https://example.com/long-url"

  const shortCode = nanoid(7)  // "V1StGXR"

  insertUrl.run(url, shortCode)
  // SQL: INSERT INTO urls (original_url, short_code) VALUES (?, ?)

  res.json({
    shortUrl: `${BASE_URL}/${shortCode}`,
    shortCode: shortCode
  })
  // Response: { shortUrl: "http://localhost:3000/V1StGXR", shortCode: "V1StGXR" }
})

GET /:code — The Redirect

This is the Read in CRUD, and it's the magic of the whole app. When someone visits http://localhost:3000/V1StGXR, Express matches the :code parameter and runs this route:

app.get('/:code', (req, res) => {
  const { code } = req.params  // "V1StGXR"

  const row = findByCode.get(code)
  // SQL: SELECT * FROM urls WHERE short_code = "V1StGXR"
  // Returns: { original_url: "https://example.com/long-url", ... }

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

  incrementClicks.run(code)
  // SQL: UPDATE urls SET click_count = click_count + 1 WHERE short_code = "V1StGXR"

  res.redirect(301, row.original_url)
  // Sends HTTP 301 (Moved Permanently) with Location: https://example.com/long-url
  // The browser automatically navigates to the original URL
})

The 301 status code tells the browser "this URL has permanently moved to the new location." The browser follows the redirect automatically — the user never sees your server, they just end up at the destination. This is the same mechanism that powers every short link service on the internet.

The Database Schema

CREATE TABLE IF NOT EXISTS urls (
  id INTEGER PRIMARY KEY AUTOINCREMENT,  -- Unique row ID
  original_url TEXT NOT NULL,            -- The long URL
  short_code TEXT NOT NULL UNIQUE,       -- The 7-char code (must be unique)
  click_count INTEGER DEFAULT 0,         -- How many times it's been visited
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP  -- When it was created
)

This is a real database schema — the same kind of thing behind every web app. Let's break down the SQL keywords:

  • PRIMARY KEY AUTOINCREMENT — each row gets a unique number. You don't set this; the database handles it.
  • NOT NULL — this field is required. The database will reject inserts that don't include it.
  • UNIQUE — no two rows can have the same value. This prevents two URLs from getting the same short code.
  • DEFAULT 0 — if you don't provide a value, it starts at 0.
  • DEFAULT CURRENT_TIMESTAMP — automatically records when the row was created.

If you query the database after creating a few short links, you'll see something like:

id | original_url                        | short_code | click_count | created_at
1  | https://github.com/some/long/repo   | V1StGXR    | 14          | 2026-03-18 10:30:00
2  | https://docs.google.com/d/abc123... | k7Hn2Pw    | 3           | 2026-03-18 10:45:00
3  | https://example.com/article?utm=... | mQ9xF3d    | 0           | 2026-03-18 11:02:00

nanoid — Why Not Just Use Math.random()?

nanoid is a tiny library (130 bytes) purpose-built for generating unique IDs. Here's why it's better than rolling your own:

  • Cryptographically random — uses crypto.getRandomValues() instead of Math.random(), which is predictable
  • URL-safe characters — only uses A-Za-z0-9_- so codes work in URLs without encoding
  • Customizable lengthnanoid(7) gives you 7 characters, nanoid(10) gives you 10
  • Collision-resistant — at 7 characters, you'd need to generate ~1.5 million IDs before you have a 1% chance of a collision

Your AI might use uuid instead (which generates longer strings like 550e8400-e29b-41d4-a716-446655440000) or a homemade function. nanoid is the right choice for URL shorteners because the codes are short, safe, and random.

What AI Gets Wrong

The code above works. But "works" and "ready for real users" are different things. Here's what your AI will miss — and what you need to add before sharing this with anyone.

1. No Collision Handling (or Bad Collision Handling)

With 7-character nanoid codes, collisions are extremely rare — but they're not impossible. Some AI-generated code doesn't handle this at all. The code above has a basic retry, but it only tries once. A production version should loop:

function generateUniqueCode(maxRetries = 5) {
  for (let i = 0; i < maxRetries; i++) {
    const code = nanoid(7)
    const existing = findByCode.get(code)
    if (!existing) return code
  }
  throw new Error('Could not generate unique code')
}

You probably won't hit this in practice until you have millions of URLs. But it's good to know it's there.

2. No Rate Limiting

Without rate limiting, anyone can write a script that creates millions of short URLs per minute, filling your database and burning your resources. AI never adds this unless you ask.

# Install the package
npm install express-rate-limit
import rateLimit from 'express-rate-limit'

// Allow 100 URL creations per IP per hour
const createLimiter = rateLimit({
  windowMs: 60 * 60 * 1000,  // 1 hour
  max: 100,
  message: { error: 'Too many URLs created. Try again later.' }
})

app.post('/api/shorten', createLimiter, (req, res) => {
  // ... existing code
})

One line of middleware. That's all it takes. Tell your AI: "Add rate limiting with express-rate-limit. 100 requests per IP per hour on the POST route."

3. No Analytics Beyond Click Count

The basic version tracks how many times a link was clicked. A real URL shortener tracks when each click happened, what country it came from, and what browser was used. If you want this, you need a separate clicks table:

CREATE TABLE clicks (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  short_code TEXT NOT NULL,
  clicked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  user_agent TEXT,
  ip_address TEXT,
  referrer TEXT,
  FOREIGN KEY (short_code) REFERENCES urls(short_code)
)

This is a good exercise for later — tell your AI to "add a clicks analytics table that logs every redirect with timestamp, user agent, and referrer. Add a GET /api/stats/:code endpoint that returns click analytics for a short code."

4. No Input Sanitization

The code validates that the URL starts with http:// or https://, which is good. But it doesn't check for other problems:

  • URLs that are already short links — someone could create a chain: short URL → short URL → short URL → actual page. This is annoying and could create infinite redirect loops.
  • URLs longer than a reasonable limit — databases have column size limits, and a malicious user could submit a 10MB URL.
  • Malicious domains — phishing sites, malware distributors. This is the hardest to solve, but at minimum you should block URLs pointing back to your own domain.
// Prevent redirect loops — don't shorten your own URLs
if (parsed.hostname === new URL(BASE_URL).hostname) {
  return res.status(400).json({
    error: 'Cannot shorten URLs from this domain'
  })
}

// Reject extremely long URLs
if (url.length > 2048) {
  return res.status(400).json({
    error: 'URL is too long (max 2048 characters)'
  })
}

5. Data Disappears on Restart (If Using In-Memory Storage)

Some AI-generated code skips the database entirely and stores URLs in a JavaScript object: const urls = {}. This works great until you restart the server — and every short link in existence breaks. The code in this article uses SQLite, which writes to a file, so your data survives restarts. But watch out for AI that takes shortcuts.

How to Debug

Things will go wrong. Here's how to figure out what happened without staring at the code for an hour.

Testing with curl

curl is a command-line tool that makes HTTP requests. It's the fastest way to test your API without clicking through the frontend:

# Create a short URL
curl -X POST http://localhost:3000/api/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "https://github.com/some/long/repo/path"}'

# Response:
# {"shortUrl":"http://localhost:3000/V1StGXR","shortCode":"V1StGXR"}

# Test the redirect (follow it)
curl -L http://localhost:3000/V1StGXR
# This follows the redirect and shows the content of the original URL

# Test the redirect (see the headers without following)
curl -I http://localhost:3000/V1StGXR
# HTTP/1.1 301 Moved Permanently
# Location: https://github.com/some/long/repo/path

# Test with an invalid URL
curl -X POST http://localhost:3000/api/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "not-a-url"}'
# {"error":"Invalid URL format"}

# Test with a nonexistent code
curl -I http://localhost:3000/doesnotexist
# HTTP/1.1 404 Not Found

The -I flag is your best friend for debugging redirects. It shows you the response headers without following the redirect, so you can see exactly where your server is sending people.

Checking the Database

If a short code isn't working, check whether it's actually in the database:

# Open the SQLite database
sqlite3 database.sqlite

# See all shortened URLs
SELECT * FROM urls ORDER BY created_at DESC LIMIT 10;

# Find a specific short code
SELECT * FROM urls WHERE short_code = 'V1StGXR';

# Check the most clicked links
SELECT short_code, original_url, click_count
FROM urls ORDER BY click_count DESC LIMIT 5;

# Exit
.quit

Common Debug Scenarios

"Shorten button does nothing" — Open DevTools (F12) → Console tab. You'll likely see a CORS error or a network error. Make sure your frontend is calling /api/shorten (relative path), not http://localhost:3000/api/shorten (absolute path). Relative paths work regardless of what port or domain you're on.

"Redirect goes to the wrong URL" — Check the database to see what was stored. Sometimes the URL gets mangled during storage. Run SELECT original_url FROM urls WHERE short_code = 'yourcode' and compare it to what you submitted.

"Everything works locally, breaks when deployed" — Almost always the BASE_URL. Locally it's http://localhost:3000, but in production it needs to be your actual domain. Set the BASE_URL environment variable on your hosting platform: BASE_URL=https://your-domain.com.

"Server crashes on startup" — Usually a missing dependency. Run npm install to make sure all packages are installed. If you see Cannot find module 'better-sqlite3', the install failed — this package compiles native code and sometimes needs build tools. Try npm install better-sqlite3 --build-from-source.

Deploying It

A URL shortener running on localhost is a demo. One running on a real domain is a product. Here's how to get there.

Option 1: Railway (Easiest)

Railway is the fastest path from code to deployed. It detects Node.js projects automatically and gives you a free PostgreSQL database.

  1. Push your code to a GitHub repository
  2. Go to Railway and connect your GitHub account
  3. Create a new project → Deploy from GitHub repo
  4. Add a PostgreSQL plugin (Railway provisions it for you)
  5. Set your environment variables:
    BASE_URL=https://your-app.up.railway.app
    DATABASE_URL=${{Postgres.DATABASE_URL}}
  6. Railway deploys automatically on every git push

SQLite vs PostgreSQL for deployment: The code above uses SQLite, which stores data in a file. This works great locally but has issues on some hosting platforms (Railway, Render) where the filesystem is ephemeral — your data disappears between deploys. For production, switch to PostgreSQL. Tell your AI: "Convert the database layer from better-sqlite3 to PostgreSQL using the pg package. Use a DATABASE_URL environment variable for the connection string."

Option 2: Render (Also Easy, Free Tier)

Render works similarly to Railway. Create a Web Service, point it at your GitHub repo, and set your environment variables. Render's free tier spins down after 15 minutes of inactivity (so the first request after idle takes ~30 seconds), but it's fine for personal use.

Option 3: VPS (Most Control)

A VPS (Virtual Private Server) gives you a full Linux machine to run whatever you want. Providers like DigitalOcean, Hetzner, or Linode cost $4-6/month.

  1. Provision a VPS with Ubuntu
  2. Install Node.js and PostgreSQL
  3. Clone your repo, run npm install
  4. Use pm2 to keep your server running: pm2 start server.js
  5. Set up nginx as a reverse proxy to handle HTTPS
  6. Point your custom domain's DNS to the VPS IP

This is more work, but you own the entire stack. No platform can shut you down or change their pricing. If you're planning to build more projects, a VPS is worth learning.

Custom Domain

The whole point of a URL shortener is short URLs. your-app.up.railway.app/V1StGXR isn't exactly short. Buy a short domain (something like lnk.to or yourbrand.link) and point it at your hosting. Most hosting platforms have documentation for custom domains — it usually takes 5 minutes to set up, plus a few hours for DNS propagation.

What to Learn Next

You just built a full-stack application — a real backend with a database and a frontend that talks to it. Here's where to go deeper on each concept you touched:

Frequently Asked Questions

Do I need a backend to build a URL shortener?

Yes. Unlike a weather app or to-do app that can run entirely in the browser, a URL shortener needs a server to store the mapping between short codes and original URLs, and to handle redirect requests. When someone visits your-domain.com/abc123, a server has to look up "abc123" in the database, find the original URL, and send back a redirect response. There's no way to do this with just frontend code.

Why use nanoid instead of just incrementing a number?

Incrementing numbers (1, 2, 3...) creates predictable URLs — anyone can guess that if /5 exists, /4 and /6 probably do too. This lets people enumerate all your shortened URLs. nanoid generates random, non-sequential codes like "V1StGXR8" that are impossible to guess. It also packs more information into fewer characters: a 7-character nanoid using letters and numbers has over 3.5 trillion possible combinations.

Can I use SQLite instead of PostgreSQL?

Absolutely. SQLite is actually the easier choice for getting started because it stores everything in a single file — no server to install or configure. The code in this tutorial uses SQLite with the better-sqlite3 package for exactly that reason. Use SQLite for local development and small projects. Switch to PostgreSQL when you deploy to production or expect significant traffic, since PostgreSQL handles concurrent connections better.

How do I prevent someone from abusing my URL shortener?

AI-generated code almost never includes abuse prevention. You need three things: (1) Rate limiting — use the express-rate-limit package to cap how many URLs one IP address can create per hour. (2) URL validation — check that submitted URLs are actually valid HTTP/HTTPS URLs, not javascript: or data: URLs that could be malicious. (3) Optional: a blocklist of known malicious domains. Start with rate limiting — it takes one line of middleware to add and stops most abuse.

Where should I deploy my URL shortener?

For a first deployment, Railway is the easiest option — it detects Node.js automatically, gives you a PostgreSQL database, and deploys from a GitHub repo with zero configuration. Render is another solid free option. If you want more control, a VPS from DigitalOcean or Hetzner costs $4-6/month and lets you run anything. Avoid Vercel for this project — it's optimized for frontend apps and serverless functions, not a persistent Express.js server with a database.