← Complete Projects

阅读中文版本

Build a URL Shortener REST API with Express and Prisma

A complete URL shortener API with Express, Prisma, PostgreSQL, and Redis, including validation, analytics, rate limiting, tests, and deployment.

intermediate~5 hexpressprismaredispostgresqlbackendtypescript

Project goal

Build a URL shortener service that accepts long URLs, returns short codes, tracks clicks, enforces rate limits, and expires old links without corrupting analytics.

Final features

  • POST /api/shorten validates and stores a URL, then returns a unique short code
  • GET /:code redirects with 302 and increments the click counter only for a live link
  • GET /api/stats/:code returns click count, creation date, and expiration
  • Redis-backed fixed-window rate limiting with a testable in-memory fallback
  • Automatic expiration after 30 days, configurable per deployment

Architecture

Express handles HTTP and validation, a service layer owns URL policy and persistence, Prisma maps the PostgreSQL schema, and Redis stores rate-limit counters. The redirect path reads the record before incrementing clicks so expired links never inflate analytics.

Prerequisites

  • Node.js 20 LTS or later
  • pnpm package manager
  • PostgreSQL 15+ (local or Docker)
  • Redis 7+ for distributed rate limiting
  • Basic understanding of REST APIs and TypeScript

Dependencies

  • express ^4.21.0 — HTTP routing and middleware
  • @prisma/client ^5.22.0 — Typed database access
  • prisma ^5.22.0 — Schema, migrations, and client generation
  • zod ^3.23.0 — Request validation
  • ioredis ^5.4.0 — Redis client for rate limiting
  • nanoid ^5.0.0 — URL-safe short code generation

Directory structure

url-shortener/
├── prisma/schema.prisma
├── src/
│   ├── index.ts
│   ├── routes.ts
│   ├── services/url.service.ts
│   ├── middleware/rateLimit.ts
│   └── lib/{prisma,redis}.ts
├── tests/api.test.ts
├── docker-compose.yml
├── .env.example
└── package.json

Phases

1. Project Initialization

Create the TypeScript project, install dependencies, and start PostgreSQL and Redis with Docker.

Files: package.json, tsconfig.json, docker-compose.yml

```yaml
services:
  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: dev
      POSTGRES_PASSWORD: devpass
      POSTGRES_DB: shortener
    ports: ['5432:5432']
  redis:
    image: redis:7-alpine
    ports: ['6379:6379']
```

Expected behavior: docker compose up -d starts both services; the API can connect using DATABASE_URL and REDIS_URL.

2. Schema and shortening service

Define the ShortUrl model and implement validation, code generation, and collision retry.

Files: prisma/schema.prisma, src/services/url.service.ts

```prisma
model ShortUrl {
  id        Int      @id @default(autoincrement())
  code      String   @unique
  longUrl   String
  clicks    Int      @default(0)
  createdAt DateTime @default(now())
  expiresAt DateTime?
}
```

Expected behavior: POST /api/shorten returns 201 with an 8-character code and a short URL.

3. Redirect and analytics

Read the record, reject missing or expired links, increment clicks for live links, and redirect with 302.

Files: src/routes.ts

```ts
app.get('/:code', async (req, res) => {
  const record = await prisma.shortUrl.findUnique({ where: { code: req.params.code } });
  if (!record || (record.expiresAt && record.expiresAt <= new Date())) {
    return res.status(404).json({ error: 'Not found or expired' });
  }
  await prisma.shortUrl.update({
    where: { id: record.id },
    data: { clicks: { increment: 1 } },
  });
  return res.redirect(302, record.longUrl);
});
```

Expected behavior: A live code returns 302 and increments clicks once; an expired or unknown code returns 404 without incrementing.

4. Rate limiting

Limit requests per IP with Redis counters and a separate in-memory adapter for tests.

Files: src/middleware/rateLimit.ts

```ts
const key = `rl:ip:${req.ip}`;
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, 60);
if (count > 10) return res.status(429).json({ error: 'Too many requests' });
next();
```

Expected behavior: The 11th request in a 60-second window returns 429 with Retry-After.

5. Integration tests

Exercise create, redirect, stats, expiry, and rate-limit behavior against a test database.

Files: tests/api.test.ts

```ts
const created = await request(app).post('/api/shorten').send({ url: 'https://example.com/a/long/path' });
expect(created.status).toBe(201);
const redirect = await request(app).get(`/${created.body.code}`);
expect(redirect.status).toBe(302);
```

Expected behavior: The test suite passes with a clean database before each test.

Testing

Run pnpm test. Use a dedicated test database and flush rate-limit keys between tests. Assert status codes, redirect Location headers, click counts, and the expired-link 404 path rather than only the happy path.

Troubleshooting

PrismaClientInitializationError: database not found

Run pnpm prisma migrate dev and confirm PostgreSQL is running with the DATABASE_URL from .env.

Duplicate short codes under concurrency

The unique constraint rejects a collision with Prisma P2002. Retry a bounded number of times with a new code.

Rate limiter blocks every test

Inject the limiter store. Use an in-memory store in tests and Redis only when the environment provides REDIS_URL.

Deployment

Deploy the API to Fly.io, Render, or a container platform with managed PostgreSQL and Redis. Run prisma migrate deploy as a release command, not during every request. Terminate TLS at the platform, set trust proxy correctly, and expose a liveness probe that does not depend on Redis.

Known limitations

  • A single PostgreSQL primary limits write throughput; read replicas and partitioning are follow-up work.
  • The fixed-window limiter allows a burst at the window boundary; use a sliding window or token bucket when the limit must be strict.
  • The service does not crawl or preview destination pages, so phishing detection requires an external reputation feed.

Project overview

A URL shortener looks like a small CRUD service: accept a URL, store it, and redirect. The production version has more edges. Codes must be unique under concurrency. Expired links must not inflate analytics. The redirect must not become an open redirect to javascript: or a phishing page without any abuse controls. The rate limiter must work across instances, and tests must not depend on a shared Redis database.

This project builds the service in phases. Each phase has a concrete artifact, a verification step, and a failure mode to test. The result is a working API with a clear data model, a tested redirect path, rate limiting, and deployment notes. It is intentionally small enough to read in one sitting but complete enough to expose the decisions a real service needs.

Architecture and data model

The HTTP layer validates input and maps results to status codes. The service layer owns URL policy: allowed schemes, length limits, code generation, collision retries, and expiry. Prisma is the only component that talks to PostgreSQL. Redis is an optimization for rate limiting, not the source of truth.

The ShortUrl table stores the code, the destination, a click counter, creation time, and an optional expiry. The code is the public identifier and has a unique index, which is also the collision detector. The numeric id is internal; using it in the URL would leak volume and invite enumeration. The destination is stored as text, not as a parsed object, because the exact string matters for redirects.

Keeping the destination validation in the service means every entry point obeys the same policy. A future import job, admin API, or custom alias feature should call the same function rather than reimplementing the checks in a route.

Phase 1: Project and infrastructure

Create the project and install the runtime and development dependencies:

mkdir url-shortener && cd url-shortener
pnpm init
pnpm add express @prisma/client zod ioredis nanoid
pnpm add -D typescript @types/express @types/node tsx vitest supertest prisma
npx tsc --init
npx prisma init --datasource-provider postgresql

Start PostgreSQL and Redis with Docker Compose, then set the environment variables. Do not commit .env; commit .env.example with placeholder values. The API should fail at startup when DATABASE_URL is missing, not on the first request.

docker compose up -d
echo 'DATABASE_URL="postgresql://dev:devpass@localhost:5432/shortener"' >> .env
echo 'REDIS_URL="redis://localhost:6379"' >> .env
npx prisma migrate dev --name init

The migration creates the table and the unique index. Run npx prisma generate after every schema change; TypeScript will otherwise use a stale client and produce confusing property errors.

Phase 2: Validate the destination

Validation is a security boundary. Accept only http and https, reject credentials in the URL, cap the length, and reject values that are not absolute URLs. A shortener is inherently an open redirect, but it does not have to accept javascript:, data:, or file: schemes.

import { z } from 'zod';

const ShortenInput = z.object({
  url: z.string().url().max(2048),
});

export function normalizeUrl(input: unknown): string {
  const { url } = ShortenInput.parse(input);
  const parsed = new URL(url);
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
    throw new Error('only http and https URLs are supported');
  }
  if (parsed.username || parsed.password) {
    throw new Error('credentials in URLs are not allowed');
  }
  return parsed.toString();
}

z.string().url() checks syntax; the protocol check enforces policy. Keep the normalized string from URL.toString() so equivalent inputs share a stable representation. If the product later supports custom aliases, validate the alias separately with a character allowlist and a reserved-word list.

A shortener can be abused for phishing. Add a destination reputation check or an interstitial page for untrusted domains, and provide an abuse-report path. Blocking private IP literals helps prevent links that point at internal services, although the browser, not the server, performs the redirect.

Phase 3: Generate codes and persist them

Generate a URL-safe code with enough entropy that guessing is impractical. Eight characters from a 64-character alphabet provide about 48 bits, which is adequate for a small service; increase the length if the expected volume is high. Collisions are still possible, so treat the unique constraint as part of the algorithm rather than an exceptional condition.

import { nanoid } from 'nanoid';
import { Prisma } from '@prisma/client';
import { prisma } from '../lib/prisma';

export async function createShortUrl(longUrl: string, ttlDays = 30) {
  const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);

  for (let attempt = 0; attempt < 5; attempt++) {
    const code = nanoid(8);
    try {
      return await prisma.shortUrl.create({
        data: { code, longUrl, expiresAt },
      });
    } catch (error) {
      if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
        continue;
      }
      throw error;
    }
  }

  throw new Error('could not allocate a unique code');
}

The retry is bounded. An unbounded loop can spin forever if the database is misconfigured or the code space is exhausted. Log the collision count as a metric; a sudden increase suggests a broken generator or an unexpectedly small alphabet.

Return 201 Created with the code and an absolute short URL. Build the short URL from a configured public base URL, not from the untrusted Host header. A spoofed Host header can otherwise produce links that point at an attacker’s domain.

Phase 4: Redirect without corrupting analytics

The redirect path has an ordering bug in many shortener tutorials: it increments clicks before checking expiry, so an expired link still records a click and returns 404. Read first, validate, then increment and redirect.

app.get('/:code', async (req, res) => {
  const record = await prisma.shortUrl.findUnique({
    where: { code: req.params.code },
  });

  if (!record || (record.expiresAt && record.expiresAt <= new Date())) {
    return res.status(404).json({ error: 'Not found or expired' });
  }

  await prisma.shortUrl.update({
    where: { id: record.id },
    data: { clicks: { increment: 1 } },
  });

  return res.redirect(302, record.longUrl);
});

Use 302 Found, not 301 Moved Permanently. A 301 can be cached indefinitely by browsers and intermediaries, which breaks click analytics, expiry, and destination changes. A 302 keeps the decision on the server. If the destination is sensitive, avoid logging the full URL and never include the code in a referrer-bearing redirect chain without a policy.

The counter update is a separate statement after the read. Two concurrent redirects can both read the same record and increment twice, which is the desired behavior for click counting. If exact counts matter, use an append-only click event table and aggregate; a single integer is an approximation and will be lost if the row is deleted.

Phase 5: Stats and error handling

The stats endpoint should return only public, non-sensitive fields: code, click count, creation time, and expiry. Do not return the full destination to unauthenticated callers if the link may be private. Use a consistent error shape and status codes:

  • 400 for malformed JSON or a missing URL.
  • 422 for a syntactically valid but disallowed URL.
  • 404 for an unknown or expired code.
  • 429 for rate limiting.
  • 500 for an unexpected server failure, with a request id in the logs.

An error middleware should log the stack and return a generic message. Do not leak Prisma errors, connection strings, or SQL to clients.

Phase 6: Rate limiting

The rate limiter protects the create endpoint from abuse and reduces accidental load on the database. A fixed-window counter is simple and adequate for a first version:

export function createRateLimiter(store: RateLimitStore, max = 10, windowSeconds = 60) {
  return async (req, res, next) => {
    const key = `rl:ip:${req.ip}`;
    const count = await store.increment(key, windowSeconds);
    if (count > max) {
      res.setHeader('Retry-After', String(windowSeconds));
      return res.status(429).json({ error: 'Too many requests' });
    }
    next();
  };
}

Inject the store instead of importing Redis directly. Production uses Redis with INCR and EXPIRE; tests use an in-memory map that is reset between cases. Configure trust proxy only for the exact number of trusted proxies in front of the app. Trusting every proxy header lets a client choose its own req.ip and bypass the limit.

A global per-IP limit is coarse. Add per-account limits when authentication exists, and apply stricter limits to the create endpoint than to redirects. Redirects are read-heavy and should be cached at the edge where possible; the create path is the expensive write.

Phase 7: Tests that cover failure paths

Use supertest against the Express app and a dedicated test database. Reset tables and rate-limit keys before each test. Cover:

  • A valid URL returns 201 and an 8-character code.
  • An invalid scheme returns 422 and does not create a row.
  • A live code returns 302 with the exact destination.
  • An expired code returns 404 and does not increment clicks.
  • A missing code returns 404.
  • The 11th create request in a window returns 429.
  • A forced P2002 collision retries and eventually succeeds or fails cleanly.

Assert the Location header, not only the status. For the expiry test, insert a record with expiresAt in the past and check the counter before and after. For the collision test, mock the generator to return the same code twice and verify the retry path.

Phase 8: Deployment and operations

Deploy the API behind HTTPS with managed PostgreSQL and Redis. Run prisma migrate deploy as a release command. Keep migrations backward compatible during rolling deploys; a migration that drops a column while old instances are still running causes failures. Use a liveness endpoint that checks only the process and a readiness endpoint that checks the database with a short timeout.

Monitor create rate, redirect rate, 4xx and 5xx counts, p95 latency, Redis errors, and database connection pool saturation. Alert on a rising 5xx rate and on rate-limit rejections that exceed the expected baseline. Log request ids and status codes, not full destination URLs. Retain click data according to a privacy policy; a shortener can reveal browsing behavior even when the destination is not logged.

Phase 9: Cache the redirect path

Redirects are read-heavy, and the database should not be consulted for every click if a link goes viral. Add a cache in front of PostgreSQL, keyed by the short code and containing only the fields needed for the redirect: destination, expiry, and record id. Redis is a reasonable first cache because it is already deployed for rate limiting.

Cache the positive result with a short TTL and a bounded stale window. Do not cache a redirect indefinitely: an expired link must stop working, and the product may allow the owner to change or disable the destination. A cache entry should include the expiry timestamp so the redirect handler can reject an expired link even if the cache has not evicted it yet. Negative caching for missing codes should be very short, otherwise a newly created code can appear broken for minutes.

When a link is updated or deleted, invalidate the cache key after the database commit. A deleted cache entry will be repopulated from the database on the next request, so the database remains the source of truth. If invalidation fails, the short TTL is the safety net; log the failure and alert rather than pretending the cache is consistent.

The cache also changes the analytics path. A cached redirect still needs to increment the click counter. Do not put the increment in a synchronous database update on every request; use a queue or an aggregated counter that is flushed periodically when the volume grows. At small scale, the direct UPDATE is fine, but measure the write rate before assuming it will scale.

Phase 10: Abuse, phishing, and privacy

A public shortener is a reputation-laundering tool unless it has abuse controls. An attacker can create a short link to a phishing page, and the short domain inherits trust from your brand. Plan for this before launch, not after the first abuse report.

Validate schemes and reject credentials in URLs, but do not pretend that makes a destination safe. Integrate a reputation feed or a blocklist for known phishing and malware domains, and provide a clear abuse-report endpoint with a monitored queue. For untrusted domains, show an interstitial page that displays the destination and requires a click; this reduces accidental visits and gives the user a chance to stop. Never fetch arbitrary user URLs from the production network without SSRF protections, because a link preview crawler can reach internal services.

Rate-limit link creation per account and per IP, and add stricter limits for new accounts. Store as little personal data as possible: a click count and coarse timestamp are enough for the product, while full IP addresses and user agents create privacy obligations. If analytics need unique visitors, hash a rotating identifier and document the retention period. Provide a deletion path for link owners and honor it in both PostgreSQL and the cache.

Phase 11: Observability and SLOs

Instrument the two critical paths separately. For creation, track requests, validation failures, collision retries, database latency, and 5xx rate. For redirects, track requests, cache hit rate, database fallback rate, 404 rate for expired links, and p95 latency. A rising 404 rate may be normal expiry or a broken link distribution; a rising 5xx rate during a deploy is a release problem.

Log one structured event per request with a request id, route, status, and duration, but do not log full destination URLs or raw IP addresses by default. Trace a sampled subset of requests through the API, cache, and database so a slow redirect can be attributed to the right layer. Alert on redirect p95 above the SLO, create p95 above its target, database connection saturation, and cache errors.

A reasonable starting SLO is 99.9% successful redirects over 30 days with p95 under 100 milliseconds at the edge. Measure it from the client’s perspective, not only from the server, because DNS, TLS, and the CDN are part of the user experience. Run a small load test before launch and again after adding caching; a change that improves the average but worsens p99 is a regression for users with slow connections.

Extensions and limitations

Useful next steps are custom aliases, authenticated link ownership, per-link expiry, click events with coarse geography, and an abuse-reporting workflow. Each adds a policy decision: who may create, who may see analytics, and how long data is retained. A dedicated click-event table makes analytics auditable and allows aggregation without mutating the link row.

The single-primary design is appropriate for a learning project and a small service. High write volume needs partitioning or a distributed database, and high redirect volume needs edge caching with a carefully chosen cache key. The fixed-window limiter is intentionally simple; a sliding window or token bucket is stricter. None of these limitations blocks the core behavior: validate, store, redirect safely, count honestly, and test the failure paths.