
August 13, 2026
A URL shortener looks simple on the surface: take a long URL, return a shorter one, and redirect users when that short code is visited. In practice, it becomes a compact distributed system with product requirements: low-latency redirects, durable storage, click analytics, abuse prevention, and support for branded domains. TypeScript is a strong fit because it gives you the safety of static types while still targeting the JavaScript ecosystem that powers Node.js and most modern serverless platforms. With Node’s ESM-oriented package configuration and TypeScript’s nodenext module mode, you can build a codebase that feels modern, interoperable, and production-ready. (nodejs.org)
Today’s URL shorteners are used well beyond casual sharing. Teams use them for marketing attribution, campaign tracking, product launches, internal dashboards, QR codes, and custom-branded links that reinforce trust. The most valuable implementations combine fast redirect paths with reliable observability: every click can feed analytics, while every redirect must remain quick enough to feel instantaneous. This guide walks through the full implementation lifecycle, from schema design to deployment optimization, using a TypeScript-first architecture.

A URL shortener maps a compact alias such as sho.rt/abc123 to a canonical destination such as https://example.com/blog/very/long/path. When someone visits the short link, the service looks up the destination and issues an HTTP redirect. Modern URL shorteners add layers around that core mapping: expiration dates, custom slugs, analytics, abuse detection, and branded domains. The shortening itself is usually write-heavy but low volume compared to reads, while redirect traffic is read-heavy and latency-sensitive.
TypeScript is especially effective here because this kind of service benefits from strict modeling. You have a small set of domain objects—links, clicks, redirect events, users, organizations—and each can be represented with precise types. That helps prevent bugs around malformed URLs, invalid expiration values, and mismatched database records. On the runtime side, TypeScript compiles cleanly to Node.js, which is a natural home for API handlers, background jobs, and database access code. Node’s modern module system supports ESM via "type": "module" in package.json, and TypeScript’s nodenext mode is designed to mirror the latest Node.js module behavior. (nodejs.org)
For real-world use cases, a URL shortener often supports:
Sharing: cleaner links for social posts, emails, docs, and QR codes.
Analytics: click counts, referrers, geolocation, device/browser metadata, and campaign attribution.
Branded links: custom domains that improve trust and conversion.
The engineering challenge is to preserve this flexibility without slowing down the redirect path. The key design principle is separation of concerns: the API that creates links can be more feature-rich, while the redirect endpoint should be as close to a pure lookup-and-forward flow as possible.
A modern 2026 setup should start with Node.js, TypeScript, and ESM-compatible module settings. Node supports ES modules when the package is marked with "type": "module", and TypeScript’s nodenext mode tracks current Node module semantics more closely than older CommonJS-oriented defaults. That matters because it keeps your imports, file extensions, and runtime behavior aligned from day one. (nodejs.org)
A practical setup looks like this:
mkdir url-shortener
cd url-shortener
npm init -y
npm install express zod
npm install -D typescript tsx eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin
npx tsc --initThen configure tsconfig.json for modern ESM behavior:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}And in package.json:
{
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"lint": "eslint ."
}
}A few setup decisions are worth calling out. First, strict TypeScript mode is worth the friction because URL handling touches user input, databases, and HTTP responses. Second, tsx is useful for development because it removes the boilerplate of a separate transpile step. Third, linting should be explicit and opinionated; URL shorteners are small enough that code quality discipline pays off quickly.
For linting, use ESLint with TypeScript support and keep the rule set focused on correctness and maintainability. If you use a formatter, separate formatting from linting so code style remains deterministic. This is also the right moment to set up environment variable validation and a .env.example file for local development. By the end of project setup, you should have a repeatable development loop, a production build pipeline, and type-safe module resolution that matches Node’s current behavior. (nodejs.org)
A URL shortener architecture is usually small but layered. At minimum, you need five components: an API layer for creating links, a redirect service for serving short URLs, an ID generation strategy for slugs, persistent storage for mapping short codes to destinations, and an optional cache or edge layer for scale.
This is where users create, manage, and inspect links. The API may expose endpoints like:
POST /api/links to shorten a URL
GET /api/links/:code to inspect metadata
PATCH /api/links/:code to update expiration or destination
DELETE /api/links/:code to deactivate a link
The redirect service handles the public short URL, often something like GET /:code. This path must be optimized for latency because every redirect adds friction to the user experience and can affect conversion.
Short codes can be:
randomly generated strings,
sequential IDs encoded in base62,
or custom slugs supplied by users.
Random codes are simpler and safer against enumeration. Sequential IDs are compact but can be easier to guess. Custom slugs are best for branded links but require collision checks.
A relational database is a natural fit because the core lookup is a simple unique key-to-record mapping. Prisma’s schema system makes it straightforward to model unique constraints, and @unique automatically maps to a unique database index in relational databases. (prisma.io)
Caching is valuable because redirect lookups are read-heavy. Cloudflare Workers can serve cached responses at the edge, and Cloudflare documents both Workers Cache and the cache behavior around Workers. That makes edge placement a strong fit for globally distributed redirect traffic. (developers.cloudflare.com)

The architectural principle is simple: writes can be moderately complex, but reads must be fast, safe, and predictable. That usually means keeping the API flexible while making the redirect path a thin lookup-and-respond layer.
The data model for a URL shortener should be minimal but expressive. At its core, you need a table that maps a short code to a destination URL. But in a production system, the schema usually also tracks ownership, creation time, status, click counts, and expiry. Unique constraints are essential because each short code must identify exactly one active destination. Prisma’s schema docs note that @unique creates a unique constraint and corresponding unique index in relational databases. (prisma.io)
A practical Prisma model might look like this:
model ShortLink {
id String @id @default(cuid())
code String @unique
originalUrl String
title String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
expiresAt DateTime?
isActive Boolean @default(true)
clickCount Int @default(0)
clicks Click[]
}
model Click {
id String @id @default(cuid())
shortLinkId String
shortLink ShortLink @relation(fields: [shortLinkId], references: [id])
clickedAt DateTime @default(now())
referrer String?
userAgent String?
country String?
}Important schema decisions:
code must be unique so redirect lookup is deterministic.
originalUrl should be long enough to hold realistic URLs.
expiresAt should be nullable because not every link expires.
isActive is useful for takedowns and moderation.
clickCount can be denormalized for fast dashboards, while detailed click events live in a separate table.
For click analytics, you need to choose between event-level storage and summary counters. Summary counters are fast, but event-level data lets you compute richer metrics later. In most systems, both are useful: increment a counter on the hot path if the database can handle it, and also write detailed click events asynchronously or in batches.
You may also want compound constraints when implementing multi-tenant or custom-domain behavior. For example, a branded link might need uniqueness across (domain, code) rather than just code. Prisma supports compound unique constraints with @@unique, which is helpful when the same slug can exist on different custom domains. (prisma.io)
The shorten endpoint is where user input becomes persistent data, so validation matters more than almost anywhere else in the app. A good POST /api/links handler should validate the URL, normalize it, generate or accept a slug, check for collisions, and store a record only if everything is safe.
A common request payload looks like this:
{
"url": "https://example.com/some/long/path",
"customCode": "launch2026",
"expiresAt": "2026-12-31T23:59:59.000Z"
}With Zod or a similar schema validator, you can enforce shape and intent:
import { z } from "zod";
export const shortenSchema = z.object({
url: z.string().url(),
customCode: z.string().regex(/^[a-zA-Z0-9_-]{4,32}$/).optional(),
expiresAt: z.string().datetime().optional()
});URL normalization is important because two URLs that look different can point to the same destination, while malformed or dangerous inputs should be rejected. At minimum, you should:
require http: or https:,
reject obviously invalid hosts,
normalize punycode and whitespace,
avoid preserving fragments if your product doesn’t need them,
and ensure the parsed URL is well-formed before saving.
A basic TypeScript implementation might look like this:
import { randomBytes } from "node:crypto";
function generateCode(length = 7): string {
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const bytes = randomBytes(length);
return Array.from(bytes, (b) => alphabet[b % alphabet.length]).join("");
}
function normalizeUrl(input: string): string {
const url = new URL(input);
if (!["http:", "https:"].includes(url.protocol)) {
throw new Error("Only http and https URLs are allowed");
}
return url.toString();
}Collision handling is straightforward but essential. If you generate a random slug and the database rejects it because the unique constraint already exists, generate a new code and retry. Because @unique is enforced by the database, it protects you even under concurrency. (prisma.io)
For custom slugs, collision handling should be user-visible. If launch2026 already exists, return a clear error rather than silently mutating the slug. That behavior is especially important for branded links where predictability matters.
A good shorten endpoint should also be idempotent if your product supports that. For example, if the same long URL is submitted repeatedly by the same authenticated user, you might choose to return the existing short link instead of creating duplicates. That is a product decision, not just a technical one.
The redirect path is the most performance-sensitive part of the system. A user clicks a short link, the server resolves the code, and the response should be a redirect with minimal overhead. The code path should avoid expensive validation, avoid large joins, and avoid unnecessary synchronous logging on the critical path.
HTTP status selection matters. MDN explains that 301 Moved Permanently indicates a permanent move, while 302 Found is temporary. 307 Temporary Redirect and 308 Permanent Redirect preserve the original method more strictly, while 301 and 302 have long historical behavior differences across clients. (developer.mozilla.org)
For most URL shorteners:
Use 302 or 307 if the destination may change and you want to preserve flexibility.
Use 301 or 308 if the short link is effectively permanent and you want stronger caching semantics.
A simple redirect handler might be:
export async function redirectHandler(code: string) {
const link = await db.shortLink.findUnique({ where: { code } });
if (!link || !link.isActive) {
return new Response("Not found", { status: 404 });
}
if (link.expiresAt && link.expiresAt < new Date()) {
return new Response("Expired", { status: 410 });
}
return Response.redirect(link.originalUrl, 302);
}Caching can dramatically reduce load. Cloudflare documents Workers caching options and notes that cached responses can be served without executing the Worker in some configurations. It also distinguishes edge and browser caching behavior, which is useful when designing redirect responses. (developers.cloudflare.com)
A practical caching strategy:
Cache successful redirect lookups at the edge.
Use a short TTL for mutable links.
Use a longer TTL for immutable or branded campaign links.
Invalidate cache when a destination changes or a link is disabled.
Edge deployment is attractive because it lowers latency for global users. If you deploy redirect logic to an edge runtime, keep the logic tiny: parse the code, fetch the mapping, return the redirect. Heavy analytics, logging enrichment, and moderation checks should be asynchronous or backgrounded when possible. The edge is for speed; the origin is for complexity.
Analytics are one of the biggest reasons teams build a custom URL shortener instead of using a generic one. A raw click count is useful, but product teams usually want more: where clicks came from, which campaigns performed best, how traffic changes over time, and whether a link is being abused.
The most common analytics fields are:
clickedAt
referrer
userAgent
country or region
deviceType
browser
ipHash or privacy-preserving network identifier
utm parameters when available
A reasonable approach is to record a lightweight click event on each redirect. If you need to keep the redirect fast, write the event asynchronously to a queue, stream, or background worker. Then build dashboards from aggregated data rather than hitting the redirect table directly.
Observability should also cover the service itself:
Latency metrics for redirect p50/p95/p99
Error rates for not found, expired, blocked, and malformed links
Database query timing
Cache hit ratio
Event ingestion lag
Queue backlog
Edge/origin split
A useful operational pattern is to log a structured event for each request:
type RedirectLog = {
code: string;
outcome: "hit" | "miss" | "expired" | "blocked";
latencyMs: number;
referrer?: string;
userAgent?: string;
};For product insights, analytics should answer questions like:
Which links are clicked most often?
Which campaigns convert best?
Which channels produce the most traffic?
Are branded links trusted more than generic ones?
Do users click more on mobile or desktop?
The best dashboards combine event-level detail with rollups. For example, you can keep detailed records for 30 to 90 days and roll up older data into daily aggregates. That gives product managers visibility without letting your analytics tables grow uncontrollably.

URL shorteners are attractive to attackers because they can hide malicious destinations behind a friendly-looking alias. That means security and abuse prevention are not optional extras; they are core product features.
Start with strict URL validation. Reject URLs with unsupported schemes such as javascript: or data:. Accept only http: and https: unless you have a very specific reason not to. Parse using the standard URL API, and normalize the destination before storing it. This reduces ambiguity and helps prevent malformed input from slipping into the system.
Next, add rate limiting. Shortening endpoints can be abused for spam, brute-force enumeration, or bulk phishing link generation. Apply different limits to authenticated users, anonymous users, and service accounts. You can rate limit by IP, account, workspace, or API key.
You should also consider link safety checks:
domain reputation screening,
malware/phishing blocklists,
suspicious redirect chains,
and takedown workflows for reported links.
A secure implementation often stores an isBlocked or reviewStatus field so moderation can disable links quickly. If a link is flagged, the redirect service should return a neutral block page rather than forwarding users to the destination.
Additional controls include:
Slug entropy: make random codes hard to guess.
Ownership checks: only creators or admins can edit/delete links.
Audit logging: record who created, modified, or disabled a link.
Signed admin actions: protect privileged operations with strong authentication.
Be careful with analytics privacy. If you capture IP addresses, consider hashing or truncation to reduce sensitivity. Keep only what you need, for only as long as you need it. In many products, the right answer is to store derived metadata rather than raw personal data.
The general rule is simple: a URL shortener must assume that every link might eventually be adversarial. Design your data model, moderation workflow, and redirect behavior accordingly.
Testing a URL shortener should cover both correctness and latency. Because the service has a tiny public surface area, it’s tempting to stop at a few unit tests. Don’t. Redirect systems fail in subtle ways: collisions, expired links, malformed URLs, race conditions, cache staleness, and inconsistent behavior between local and edge environments.
Test the pure functions first:
slug generation format,
URL normalization,
custom slug validation,
expiration checks,
and redirect status selection.
These tests should be fast and deterministic.
Test against a real database instance or a containerized test database. Verify:
unique constraint enforcement,
create-and-read workflows,
collision retries,
expiry behavior,
and deletion/takedown flows.
Since Prisma unique constraints map to database uniqueness, integration tests are where you verify the schema does what you expect under concurrency. (prisma.io)
Because the redirect path is the hottest path, load test it separately from the shortening endpoint. Measure:
average and tail latency,
cache hit rates,
database query counts,
and error behavior under spikes.
A useful benchmark is not just “can it handle traffic?” but “does p95 stay acceptable when the database is slow or the cache is cold?”
Test:
invalid protocols,
URLs with internationalized domains,
extremely long inputs,
duplicate custom slugs,
expired but still cached links,
and links disabled while requests are in flight.
A mature test suite should also assert observability behavior. For example, when a link is blocked, does the service emit the right event? When a redirect succeeds, is the click event queued exactly once? Those tests are especially useful in distributed systems where the API response and analytics side effects may diverge.
Deployment depends on your traffic pattern and operational preferences. A small URL shortener can run well in either serverless functions or containers. Serverless is appealing if you want automatic scaling and minimal maintenance. Containers are a good fit if you want predictable runtime behavior, warm connections, and more direct control over networking, background workers, and custom process tuning.
For environment configuration, separate production secrets from code:
database connection string,
cache/queue credentials,
analytics sink endpoints,
blocklist API keys,
and custom domain settings.
Use schema-validated environment loading so your app fails fast when configuration is incomplete.
Monitoring should include:
redirect latency,
error rate,
database health,
cache hit rate,
event queue lag,
and blocklist lookup performance.
A URL shortener also benefits from a roadmap of incremental enhancements:
Support go.company.com or links.brand.com to improve trust and brand consistency. This usually requires domain ownership verification and compound uniqueness across domain plus code.
Allow custom slugs such as /launch, /pricing, or /docs. These are useful for campaigns, internal teams, and memorable sharing.
Add scheduled activation, expiration, temporary redirects, and archival states.
Useful for marketing teams and migrations from other link platforms.
Emit events when links are created, clicked, blocked, or updated.
If your users are global, edge caching and edge execution become increasingly important. Cloudflare’s documentation on Worker caching and cache behavior illustrates why redirect services are a strong candidate for edge deployment. (developers.cloudflare.com)
A final optimization pattern is to split the system into a write path and a read path. The write path can afford validation, moderation, and richer metadata. The read path should be as close to a pure key-value lookup as possible. That design keeps the service fast, scalable, and easy to reason about.
Building a URL shortener in TypeScript is a compact but realistic exercise in modern backend engineering. You get to combine strong typing, Node.js ESM tooling, relational schema design, redirect performance, cache strategy, and production-grade observability in a single project. The key architectural idea is to keep the redirect path lean while shifting complexity to the create, analytics, and moderation layers.
If you design the service carefully, you can support:
fast redirects,
durable and unique short codes,
actionable analytics,
secure link validation,
and future growth through branded domains and edge deployment.
In other words, a URL shortener is not just a toy project. Done well, it becomes a small but complete distributed system with practical product value.