URL Shortener Tutorial · Module 02 of 11

Core API & Data Modeling

By the end of this tutorial, you'll have Postgres running locally in Docker, a versioned schema with migrations, and three working endpoints: shorten, redirect, delete.

~4–6 hrsBeginnerExpress + Postgres
← Back to Module 02 overview
Before You Start

Prerequisites

  • Module 01 complete: working TypeScript Express server.
  • Docker Desktop installed and running.
  • A terminal in the project root.
What You'll Have at the End

Definition of Done

  • Postgres running in Docker with persistent data.
  • Migration framework wired up; one applied migration.
  • POST /shorten returns 201 with a code.
  • GET /:code 302-redirects to the target.
  • DELETE /:code returns 204.
  • Bad input rejected with a 400 and helpful message.
The Steps

Build It

STEP 1

Run Postgres in Docker

Create docker-compose.yml:

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: shortener
      POSTGRES_PASSWORD: dev
      POSTGRES_DB: shortener
    ports: ["5432:5432"]
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U shortener"]
      interval: 3s
      timeout: 3s
      retries: 10

volumes:
  pgdata:
docker compose up -d
✓ Verify: docker compose ps shows the db container as healthy.
STEP 2

Configure environment variables

Create .env:

DATABASE_URL=postgres://shortener:dev@localhost:5432/shortener
PORT=3000
BASE_URL=http://localhost:3000

And .env.example with the same keys but no real values — commit this one.

npm install dotenv pg
npm install -D @types/pg
STEP 3

Pick a migration tool — node-pg-migrate

npm install -D node-pg-migrate
npm pkg set scripts.migrate="node-pg-migrate -m migrations -j ts"
npm pkg set scripts.migrate:up="npm run migrate up"
npm pkg set scripts.migrate:down="npm run migrate down"
STEP 4

Create the schema migration

npm run migrate -- create init

Open the generated file under migrations/ and replace its body with:

import { MigrationBuilder } from 'node-pg-migrate';

export const up = (pgm: MigrationBuilder) => {
  pgm.createTable('links', {
    id:         { type: 'bigserial', primaryKey: true },
    code:       { type: 'text', notNull: true, unique: true },
    target_url: { type: 'text', notNull: true },
    created_at: { type: 'timestamptz', notNull: true, default: pgm.func('now()') }
  });
  pgm.addConstraint('links', 'chk_links_url',
    { check: "target_url ~* '^https?://'" });
};

export const down = (pgm: MigrationBuilder) => pgm.dropTable('links');

Run it:

node --env-file=.env --loader tsx node_modules/node-pg-migrate/bin/node-pg-migrate.cjs up -m migrations -j ts

(Or just put DATABASE_URL in your shell and run npm run migrate:up.)

✓ Verify: docker exec -it $(docker compose ps -q db) psql -U shortener -c '\d links' shows the table.
STEP 5

Add a database client

Create src/db.ts:

import 'dotenv/config';
import pg from 'pg';

export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
STEP 6

Write the base62 encoder

Create src/code.ts:

const ALPHABET =
  '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';

export function randomCode(length = 7): string {
  let out = '';
  for (let i = 0; i < length; i++) {
    out += ALPHABET[Math.floor(Math.random() * ALPHABET.length)];
  }
  return out;
}
Gotcha: Math.random is fine for v1, but in Module 09 we'll swap it for crypto.randomInt for unguessability.
STEP 7

Wire up the routes

Replace src/server.ts:

import 'dotenv/config';
import express from 'express';
import { pool } from './db.js';
import { randomCode } from './code.js';

const app  = express();
const port = Number(process.env.PORT ?? 3000);
const base = process.env.BASE_URL ?? `http://localhost:${port}`;

app.use(express.json());

app.get('/health', (_req, res) => res.json({ ok: true }));

app.post('/shorten', async (req, res) => {
  const { url } = req.body ?? {};
  if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
    return res.status(400).json({ error: 'url must be http(s)://...' });
  }
  if (url.length > 2048) {
    return res.status(400).json({ error: 'url too long' });
  }

  // Try a few times in case of collision (very unlikely at 7 chars).
  for (let i = 0; i < 5; i++) {
    const code = randomCode();
    try {
      await pool.query(
        'INSERT INTO links(code, target_url) VALUES ($1, $2)',
        [code, url]
      );
      return res.status(201).json({ code, short: `${base}/${code}` });
    } catch (e: unknown) {
      const err = e as { code?: string };
      if (err.code !== '23505') throw e;        // 23505 = unique violation
    }
  }
  res.status(500).json({ error: 'could not allocate code' });
});

app.get('/:code', async (req, res) => {
  const { rows } = await pool.query(
    'SELECT target_url FROM links WHERE code = $1',
    [req.params.code]
  );
  if (rows.length === 0) return res.sendStatus(404);
  res.redirect(302, rows[0].target_url);
});

app.delete('/:code', async (req, res) => {
  const result = await pool.query(
    'DELETE FROM links WHERE code = $1',
    [req.params.code]
  );
  res.sendStatus(result.rowCount === 0 ? 404 : 204);
});

app.listen(port, () => console.log(`listening on :${port}`));
STEP 8

Try it end-to-end

npm run dev

In a second terminal:

# Create a short link
curl -s -X POST http://localhost:3000/shorten \
  -H 'content-type: application/json' \
  -d '{"url":"https://example.com/some/long/path"}'
# → {"code":"aB3xY12","short":"http://localhost:3000/aB3xY12"}

# Follow the redirect (-i shows headers, -L follows)
curl -i http://localhost:3000/aB3xY12
# → HTTP/1.1 302 Found
# → Location: https://example.com/some/long/path

# Bad input
curl -s -X POST http://localhost:3000/shorten \
  -H 'content-type: application/json' \
  -d '{"url":"javascript:alert(1)"}'
# → {"error":"url must be http(s)://..."}

# Delete
curl -i -X DELETE http://localhost:3000/aB3xY12
# → HTTP/1.1 204 No Content
✓ Verify: all four commands behave as commented.
STEP 9

Commit your progress

git checkout -b module-02
git add .
git commit -m "module 02: links table, shorten/redirect/delete endpoints"
git push -u origin module-02

Open a PR. Self-merge for now (CI gates land in Module 08).

Common Gotchas

If Something Goes Wrong

  • ECONNREFUSED 127.0.0.1:5432 — Postgres isn't running. docker compose up -d.
  • Migration error "ECONNREFUSED" — DATABASE_URL not loaded. Make sure you exported it or are running through dotenv.
  • 302 redirect doesn't work in browser — type the URL directly; some browsers treat localhost autocomplete oddly.
  • "req.body is undefined" — check that app.use(express.json()) appears before the route handlers.
What's Next

Move On

It works on the happy path. Now we make sure it stays working — tests, including a TDD pass on the encoder.