Back to blog

Build a URL Shortener: Project Setup & API

typescriptnodejsexpressapibackend
Build a URL Shortener: Project Setup & API

Time to write code. In this post, we'll go from an empty directory to a working URL shortener API. You'll paste a long URL, get a short code back, and visit that short code to get redirected — all running locally with in-memory storage.

No database yet. No Redis. No frontend. Just Express, TypeScript, and clean API design. We'll add persistence in the next post.

Time commitment: 1–2 hours
Prerequisites: Series Overview & Architecture

What we'll build in this post:
✅ Express + TypeScript project with proper folder structure
POST /api/shorten — shorten a URL and get a short code
GET /:code — redirect to the original URL
GET /api/urls/:code — get URL info and click count
✅ URL validation, error handling middleware, and environment config


Project Scaffolding

Let's initialize the project:

mkdir url-shortener && cd url-shortener
npm init -y

Install Dependencies

# Runtime dependencies
npm install express dotenv nanoid@3
 
# Development dependencies
npm install -D typescript @types/express @types/node tsx nodemon

Why these packages:

  • express — HTTP framework
  • dotenv — load environment variables from .env
  • nanoid@3 — fast, secure, URL-friendly ID generator (v3 for CommonJS compatibility)
  • tsx — run TypeScript directly without a build step (development)
  • nodemon — auto-restart on file changes

TypeScript Configuration

npx tsc --init

Update tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Key choices:

  • target: ES2022 — modern JavaScript features (top-level await, Array.at())
  • strict: true — catch bugs early with full type checking
  • outDir/rootDir — clean separation between source and build output

NPM Scripts

Update package.json:

{
  "scripts": {
    "dev": "nodemon --watch src --ext ts --exec tsx src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js",
    "lint": "tsc --noEmit"
  }
}

Environment Configuration

Create .env:

PORT=3000
BASE_URL=http://localhost:3000
NODE_ENV=development

Create .env.example (committed to git):

PORT=3000
BASE_URL=http://localhost:3000
NODE_ENV=development

Add .gitignore:

node_modules/
dist/
.env
*.log

Folder Structure

Here's the structure we'll build toward. It's intentionally simple now — we'll add more directories as the project grows:

url-shortener/
├── src/
│   ├── config/
│   │   └── env.ts              # Environment variable loading
│   ├── middleware/
│   │   ├── errorHandler.ts     # Global error handling
│   │   └── validateRequest.ts  # Request validation helper
│   ├── routes/
│   │   ├── urlRoutes.ts        # URL shortening endpoints
│   │   └── redirectRoutes.ts   # Redirect endpoint
│   ├── services/
│   │   └── urlService.ts       # Business logic
│   ├── store/
│   │   └── memoryStore.ts      # In-memory URL storage
│   ├── utils/
│   │   └── shortCode.ts        # Short code generation
│   ├── types/
│   │   └── url.ts              # TypeScript interfaces
│   ├── app.ts                  # Express app setup
│   └── index.ts                # Server entry point
├── .env
├── .env.example
├── .gitignore
├── package.json
└── tsconfig.json

Why this structure:

  • config/ — centralized environment config with validation
  • routes/ — route definitions separated from logic
  • services/ — business logic decoupled from HTTP layer
  • store/ — data access layer (memory now, PostgreSQL later)
  • middleware/ — reusable Express middleware
  • types/ — shared TypeScript interfaces

This isn't overengineered — it's the minimum structure that lets us swap out the storage layer without rewriting routes or services.


Type Definitions

Start with the core types. These define what a URL record looks like:

// src/types/url.ts
 
export interface UrlRecord {
  id: string;
  shortCode: string;
  originalUrl: string;
  createdAt: Date;
  clickCount: number;
}
 
export interface CreateUrlRequest {
  url: string;
  customAlias?: string;
}
 
export interface CreateUrlResponse {
  shortCode: string;
  shortUrl: string;
  originalUrl: string;
  createdAt: Date;
}
 
export interface UrlStatsResponse {
  shortCode: string;
  shortUrl: string;
  originalUrl: string;
  createdAt: Date;
  clickCount: number;
}

Separating request/response types from the internal record gives us flexibility. The UrlRecord has an id field, but we don't expose it in API responses — short codes are the public identifier.


Environment Configuration

Load and validate environment variables once at startup:

// src/config/env.ts
 
import dotenv from 'dotenv';
 
dotenv.config();
 
export const env = {
  port: parseInt(process.env.PORT || '3000', 10),
  baseUrl: process.env.BASE_URL || 'http://localhost:3000',
  nodeEnv: process.env.NODE_ENV || 'development',
} as const;

Using as const makes these values readonly at the type level. If any variable is missing, we fall back to sensible defaults.


In-Memory Storage

Before adding a database, we need somewhere to store URLs. A Map is perfect for development:

// src/store/memoryStore.ts
 
import { UrlRecord } from '../types/url';
 
const urls = new Map<string, UrlRecord>();
 
export const memoryStore = {
  save(record: UrlRecord): UrlRecord {
    urls.set(record.shortCode, record);
    return record;
  },
 
  findByShortCode(shortCode: string): UrlRecord | undefined {
    return urls.get(shortCode);
  },
 
  findByOriginalUrl(originalUrl: string): UrlRecord | undefined {
    for (const record of urls.values()) {
      if (record.originalUrl === originalUrl) {
        return record;
      }
    }
    return undefined;
  },
 
  incrementClickCount(shortCode: string): void {
    const record = urls.get(shortCode);
    if (record) {
      record.clickCount += 1;
    }
  },
 
  exists(shortCode: string): boolean {
    return urls.has(shortCode);
  },
};

The interface here — save, findByShortCode, findByOriginalUrl, incrementClickCount — mirrors what we'll implement with Prisma later. When we swap to PostgreSQL, only this file changes.


Short Code Generation

The heart of any URL shortener. We need codes that are:

  • Short — 7 characters by default
  • URL-safe — only alphanumeric characters
  • Unique — no collisions (or at least, handle them)
// src/utils/shortCode.ts
 
import { nanoid, customAlphabet } from 'nanoid';
 
// Base62 alphabet: a-z, A-Z, 0-9
const ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const DEFAULT_LENGTH = 7;
 
const generate = customAlphabet(ALPHABET, DEFAULT_LENGTH);
 
export function generateShortCode(length: number = DEFAULT_LENGTH): string {
  return generate(length);
}
 
export function isValidCustomAlias(alias: string): boolean {
  if (alias.length < 3 || alias.length > 30) {
    return false;
  }
 
  // Allow alphanumeric, hyphens, and underscores
  const validPattern = /^[a-zA-Z0-9_-]+$/;
  return validPattern.test(alias);
}

Why nanoid over UUID or Math.random?

ApproachExampleLengthURL-safeCollision Risk
UUID v4550e8400-e29b-41d4-a716-44665544000036No (hyphens)Negligible
Math.random0.7381629VariableNo (dot)High
nanoid (Base62)aBc1X9z7YesLow*

*With 7 characters from a 62-character alphabet, we get 62^7 = 3.5 trillion possible codes. At 1,000 URLs per day, it would take ~9.6 million years to exhaust the space. We'll still handle collisions in Post #4.

Why customAlphabet?

The default nanoid() uses A-Za-z0-9_- (64 characters). We use customAlphabet to stick with pure Base62 (62 characters) — no hyphens or underscores in our codes. This keeps short URLs clean and avoids issues with URL parsing.


URL Validation

We need to validate that users submit actual URLs, not garbage:

// src/middleware/validateRequest.ts
 
import { Request, Response, NextFunction } from 'express';
 
export function validateShortenRequest(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  const { url, customAlias } = req.body;
 
  // Check URL is present
  if (!url || typeof url !== 'string') {
    res.status(400).json({
      error: 'URL is required',
      message: 'Please provide a valid URL in the request body',
    });
    return;
  }
 
  // Trim and validate URL format
  const trimmedUrl = url.trim();
 
  try {
    const parsed = new URL(trimmedUrl);
 
    // Only allow http and https protocols
    if (!['http:', 'https:'].includes(parsed.protocol)) {
      res.status(400).json({
        error: 'Invalid protocol',
        message: 'Only HTTP and HTTPS URLs are supported',
      });
      return;
    }
  } catch {
    res.status(400).json({
      error: 'Invalid URL',
      message: 'The provided string is not a valid URL',
    });
    return;
  }
 
  // Validate custom alias if provided
  if (customAlias !== undefined) {
    if (typeof customAlias !== 'string') {
      res.status(400).json({
        error: 'Invalid alias',
        message: 'Custom alias must be a string',
      });
      return;
    }
 
    const validPattern = /^[a-zA-Z0-9_-]+$/;
    if (customAlias.length < 3 || customAlias.length > 30 || !validPattern.test(customAlias)) {
      res.status(400).json({
        error: 'Invalid alias',
        message: 'Custom alias must be 3-30 characters, alphanumeric with hyphens and underscores',
      });
      return;
    }
  }
 
  // Attach cleaned URL to request
  req.body.url = trimmedUrl;
  next();
}

Why URL Validation Matters

Without validation, attackers could:

  • Submit javascript:alert('xss') — XSS via redirect
  • Submit file:///etc/passwd — local file access attempts
  • Submit empty strings or massive payloads — crash the server

We restrict to http: and https: protocols and use the built-in URL constructor, which handles edge cases like malformed Unicode and missing TLDs.


URL Service (Business Logic)

The service layer contains all the logic. Routes call the service — the service doesn't know about Express:

// src/services/urlService.ts
 
import { memoryStore } from '../store/memoryStore';
import { generateShortCode, isValidCustomAlias } from '../utils/shortCode';
import { env } from '../config/env';
import { CreateUrlResponse, UrlStatsResponse } from '../types/url';
 
export class UrlService {
  async shortenUrl(originalUrl: string, customAlias?: string): Promise<CreateUrlResponse> {
    // Check if URL was already shortened
    const existing = memoryStore.findByOriginalUrl(originalUrl);
    if (existing && !customAlias) {
      return this.toCreateResponse(existing.shortCode, existing.originalUrl, existing.createdAt);
    }
 
    // Use custom alias or generate a short code
    let shortCode: string;
 
    if (customAlias) {
      if (!isValidCustomAlias(customAlias)) {
        throw new AppError('Invalid custom alias. Use 3-30 alphanumeric characters, hyphens, or underscores.', 400);
      }
 
      if (memoryStore.exists(customAlias)) {
        throw new AppError('Custom alias is already taken', 409);
      }
 
      shortCode = customAlias;
    } else {
      shortCode = await this.generateUniqueCode();
    }
 
    // Save the record
    const record = memoryStore.save({
      id: crypto.randomUUID(),
      shortCode,
      originalUrl,
      createdAt: new Date(),
      clickCount: 0,
    });
 
    return this.toCreateResponse(record.shortCode, record.originalUrl, record.createdAt);
  }
 
  async resolveAndTrack(shortCode: string): Promise<string> {
    const record = memoryStore.findByShortCode(shortCode);
 
    if (!record) {
      throw new AppError('Short URL not found', 404);
    }
 
    // Increment click count
    memoryStore.incrementClickCount(shortCode);
 
    return record.originalUrl;
  }
 
  async getUrlStats(shortCode: string): Promise<UrlStatsResponse> {
    const record = memoryStore.findByShortCode(shortCode);
 
    if (!record) {
      throw new AppError('Short URL not found', 404);
    }
 
    return {
      shortCode: record.shortCode,
      shortUrl: `${env.baseUrl}/${record.shortCode}`,
      originalUrl: record.originalUrl,
      createdAt: record.createdAt,
      clickCount: record.clickCount,
    };
  }
 
  private async generateUniqueCode(): Promise<string> {
    const MAX_RETRIES = 5;
 
    for (let i = 0; i < MAX_RETRIES; i++) {
      const code = generateShortCode();
      if (!memoryStore.exists(code)) {
        return code;
      }
    }
 
    throw new AppError('Failed to generate unique short code. Please try again.', 500);
  }
 
  private toCreateResponse(shortCode: string, originalUrl: string, createdAt: Date): CreateUrlResponse {
    return {
      shortCode,
      shortUrl: `${env.baseUrl}/${shortCode}`,
      originalUrl,
      createdAt,
    };
  }
}
 
// Custom error class with HTTP status code
export class AppError extends Error {
  constructor(
    message: string,
    public statusCode: number
  ) {
    super(message);
    this.name = 'AppError';
  }
}

Key Design Decisions

URL deduplication — if someone shortens the same URL twice (without a custom alias), we return the existing short code. This prevents the database from filling up with duplicates. We'll make this configurable later.

Retry loop for collisionsgenerateUniqueCode() tries up to 5 times to find an unused code. With 3.5 trillion possible 7-character codes, collisions are extremely rare. But handling them is the difference between a demo and a real system.

Service is async — even though memoryStore is synchronous, we use async methods. This means zero changes when we swap to Prisma's async database calls.


Error Handling Middleware

Express needs a centralized error handler. Without one, unhandled errors crash the server:

// src/middleware/errorHandler.ts
 
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../services/urlService';
 
export function errorHandler(
  err: Error,
  _req: Request,
  res: Response,
  _next: NextFunction
): void {
  // Handle known application errors
  if (err instanceof AppError) {
    res.status(err.statusCode).json({
      error: err.message,
    });
    return;
  }
 
  // Handle JSON parse errors
  if (err.type === 'entity.parse.failed') {
    res.status(400).json({
      error: 'Invalid JSON in request body',
    });
    return;
  }
 
  // Log unexpected errors
  console.error('Unexpected error:', err);
 
  res.status(500).json({
    error: 'Internal server error',
  });
}

The AppError class carries an HTTP status code. Our service throws AppError with the right code (400, 404, 409), and the error handler translates it to a JSON response. Unknown errors always return 500 — never leak stack traces to clients.

Note: The err.type check uses a property set by Express's body-parser when JSON parsing fails. TypeScript may flag this — you can use (err as any).type or extend the Error type.


Routes

URL Shortening Routes

// src/routes/urlRoutes.ts
 
import { Router, Request, Response, NextFunction } from 'express';
import { UrlService } from '../services/urlService';
import { validateShortenRequest } from '../middleware/validateRequest';
 
const router = Router();
const urlService = new UrlService();
 
// POST /api/shorten — create a short URL
router.post(
  '/shorten',
  validateShortenRequest,
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const { url, customAlias } = req.body;
      const result = await urlService.shortenUrl(url, customAlias);
      res.status(201).json(result);
    } catch (error) {
      next(error);
    }
  }
);
 
// GET /api/urls/:code — get URL info and stats
router.get(
  '/urls/:code',
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const stats = await urlService.getUrlStats(req.params.code);
      res.json(stats);
    } catch (error) {
      next(error);
    }
  }
);
 
export default router;

Redirect Routes

// src/routes/redirectRoutes.ts
 
import { Router, Request, Response, NextFunction } from 'express';
import { UrlService } from '../services/urlService';
 
const router = Router();
const urlService = new UrlService();
 
// GET /:code — redirect to original URL
router.get(
  '/:code',
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      const originalUrl = await urlService.resolveAndTrack(req.params.code);
      res.redirect(302, originalUrl);
    } catch (error) {
      next(error);
    }
  }
);
 
export default router;

Why Separate Route Files?

The redirect route (GET /:code) is mounted at the root. API routes (/api/*) are mounted under a prefix. Keeping them separate makes the mounting order clear in app.ts — API routes must come before the catch-all redirect route.

301 vs 302 Redirects

We use 302 (temporary redirect) for now. Here's why:

StatusTypeBrowser BehaviorSEO Effect
301PermanentCaches forever, never hits our server againPasses link equity to destination
302TemporaryHits our server every timeNo link equity transfer

We want 302 because:

  1. Analytics — every redirect goes through our server, so we can count clicks
  2. Flexibility — we can change the destination URL later
  3. Expiration — we can disable short URLs without browsers caching the old redirect

We'll make this configurable in a later post. Some use cases (like permanent marketing links) benefit from 301.


Express App Setup

Wire everything together:

// src/app.ts
 
import express from 'express';
import urlRoutes from './routes/urlRoutes';
import redirectRoutes from './routes/redirectRoutes';
import { errorHandler } from './middleware/errorHandler';
 
const app = express();
 
// Middleware
app.use(express.json({ limit: '10kb' }));
 
// Health check
app.get('/health', (_req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
 
// API routes (must come before redirect catch-all)
app.use('/api', urlRoutes);
 
// Redirect catch-all (must be last)
app.use(redirectRoutes);
 
// Error handler (must be after all routes)
app.use(errorHandler);
 
export default app;

Order matters:

  1. express.json() — parse JSON bodies first
  2. /health — health check before any complex logic
  3. /api/* — API routes matched first
  4. /:code — catch-all redirect matched last
  5. errorHandler — catches any errors from above

If we put /:code before /api/*, requests to /api/shorten would match /:code with code = "api" and try to redirect — not what we want.

Body Size Limit

express.json({ limit: '10kb' }) prevents attackers from sending massive JSON payloads. A URL is typically under 2KB. 10KB gives plenty of room while blocking abuse.


Server Entry Point

// src/index.ts
 
import app from './app';
import { env } from './config/env';
 
const server = app.listen(env.port, () => {
  console.log(`🚀 URL Shortener running at ${env.baseUrl}`);
  console.log(`   Environment: ${env.nodeEnv}`);
  console.log(`   Health check: ${env.baseUrl}/health`);
});
 
// Graceful shutdown
process.on('SIGTERM', () => {
  console.log('SIGTERM received. Shutting down gracefully...');
  server.close(() => {
    console.log('Server closed');
    process.exit(0);
  });
});

Graceful shutdown is important even in development. When you hit Ctrl+C, the server finishes processing in-flight requests before exiting instead of dropping connections.


Testing It Out

Start the development server:

npm run dev

You should see:

🚀 URL Shortener running at http://localhost:3000
   Environment: development
   Health check: http://localhost:3000/health

Test 1: Health Check

curl http://localhost:3000/health
{
  "status": "ok",
  "timestamp": "2026-03-20T10:00:00.000Z"
}

Test 2: Shorten a URL

curl -X POST http://localhost:3000/api/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "https://github.com/expressjs/express"}'
{
  "shortCode": "aBc1X9z",
  "shortUrl": "http://localhost:3000/aBc1X9z",
  "originalUrl": "https://github.com/expressjs/express",
  "createdAt": "2026-03-20T10:00:05.000Z"
}

Test 3: Shorten with Custom Alias

curl -X POST http://localhost:3000/api/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "https://github.com/expressjs/express", "customAlias": "express-repo"}'
{
  "shortCode": "express-repo",
  "shortUrl": "http://localhost:3000/express-repo",
  "originalUrl": "https://github.com/expressjs/express",
  "createdAt": "2026-03-20T10:00:10.000Z"
}

Test 4: Redirect

curl -v http://localhost:3000/aBc1X9z
< HTTP/1.1 302 Found
< Location: https://github.com/expressjs/express

Or open http://localhost:3000/aBc1X9z in your browser — it redirects to GitHub.

Test 5: Get URL Stats

curl http://localhost:3000/api/urls/aBc1X9z
{
  "shortCode": "aBc1X9z",
  "shortUrl": "http://localhost:3000/aBc1X9z",
  "originalUrl": "https://github.com/expressjs/express",
  "createdAt": "2026-03-20T10:00:05.000Z",
  "clickCount": 1
}

Test 6: Error Cases

Invalid URL:

curl -X POST http://localhost:3000/api/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "not-a-url"}'
{
  "error": "Invalid URL",
  "message": "The provided string is not a valid URL"
}

Missing URL:

curl -X POST http://localhost:3000/api/shorten \
  -H "Content-Type: application/json" \
  -d '{}'
{
  "error": "URL is required",
  "message": "Please provide a valid URL in the request body"
}

Non-existent short code:

curl http://localhost:3000/api/urls/doesnotexist
{
  "error": "Short URL not found"
}

Duplicate custom alias:

curl -X POST http://localhost:3000/api/shorten \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com", "customAlias": "express-repo"}'
{
  "error": "Custom alias is already taken"
}

Understanding the Request Flow

Let's trace what happens when a user shortens a URL:

And what happens during a redirect:


Project State So Far

Here's the complete file tree after this post:

url-shortener/
├── src/
│   ├── config/
│   │   └── env.ts
│   ├── middleware/
│   │   ├── errorHandler.ts
│   │   └── validateRequest.ts
│   ├── routes/
│   │   ├── urlRoutes.ts
│   │   └── redirectRoutes.ts
│   ├── services/
│   │   └── urlService.ts
│   ├── store/
│   │   └── memoryStore.ts
│   ├── utils/
│   │   └── shortCode.ts
│   ├── types/
│   │   └── url.ts
│   ├── app.ts
│   └── index.ts
├── .env
├── .env.example
├── .gitignore
├── package.json
└── tsconfig.json

API Summary

MethodEndpointDescriptionStatus Codes
POST/api/shortenShorten a URL201, 400, 409, 500
GET/api/urls/:codeGet URL info and stats200, 404
GET/:codeRedirect to original URL302, 404
GET/healthHealth check200

Common Mistakes to Avoid

1. Not Validating URLs

Never trust user input. Without validation, someone could submit javascript:alert(1) and your shortener becomes an XSS vector.

2. Route Order Matters

// WRONG — /:code catches everything, including /api/*
app.use(redirectRoutes);  // GET /:code
app.use('/api', urlRoutes);
 
// CORRECT — /api/* matched first
app.use('/api', urlRoutes);
app.use(redirectRoutes);  // GET /:code

3. Forgetting Error Handling in Async Routes

// WRONG — uncaught async error crashes the server
router.post('/shorten', async (req, res) => {
  const result = await urlService.shortenUrl(req.body.url);
  res.json(result);
});
 
// CORRECT — errors forwarded to error handler
router.post('/shorten', async (req, res, next) => {
  try {
    const result = await urlService.shortenUrl(req.body.url);
    res.json(result);
  } catch (error) {
    next(error);
  }
});

4. Using 301 for Redirects Too Early

301 (permanent redirect) is cached by browsers — you can't undo it. Start with 302 and switch to 301 only when you're sure the destination won't change.


What's Next?

Our URL shortener works, but it forgets everything when the server restarts. In Post #3, we'll add PostgreSQL with Prisma ORM:

  • Docker Compose for PostgreSQL
  • Prisma schema design with migrations
  • Replace memoryStore with database queries
  • Indexing strategy for fast short code lookups
  • Connection pooling configuration

The beauty of our layered architecture: only the store/ layer changes. Routes and services stay the same.

Series: Build a URL Shortener
Previous: Series Overview & Architecture
Next: Phase 2: Database Design & URL Storage

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.