Building a URL Shortener with Next.js
How to design the data model, slug generator, and IP hashing for a short-link service using Prisma, Web Crypto, and HMAC-SHA256 for anonymised click tra...
A URL shortener seems simple on the surface: generate a short slug, store the mapping, redirect on visit. But the details matter. You need slug generation that doesn't collide, click tracking that respects privacy, and a data model that handles revocation and deduplication. Here is how I built the foundational pieces.
The Data Model
The short-link feature needs two tables: one for the links themselves and one for click events. I use Prisma for the schema because it gives me type-safe queries and migrations out of the box.
model ShortLink {
id String @id @default(cuid())
slug String @unique
originalUrl String @map("original_url")
postId String? @map("post_id")
post Post? @relation(fields: [postId], references: [id])
revokedAt DateTime? @map("revoked_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
clicks ShortLinkClick[]
@@map("short_links")
}
model ShortLinkClick {
id String @id @default(cuid())
shortLinkId String @map("short_link_id")
shortLink ShortLink @relation(fields: [shortLinkId], references: [id])
ipHash String @map("ip_hash")
userAgent String? @map("user_agent")
referrer String?
clickedAt DateTime @default(now()) @map("clicked_at")
@@map("short_link_clicks")
}
The revokedAt field handles link takedown without deletion. When a link is revoked, redirects stop but the data stays for analytics. Setting revokedAt to a timestamp instead of a boolean means you can schedule future revocations if you need them.
The postId field links a short link to a specific piece of content. This matters for deduplication: you don't want two active short links pointing to the same content with the same URL. Which leads to a constraint you can't express in Prisma's DSL.
The Partial Unique Index
You need a unique index on (postId, originalUrl) but only for rows where revokedAt is null. This ensures that for any given content item, there's at most one active short link pointing to a given URL. Prisma doesn't support partial unique indexes, so you add it with raw SQL after the first prisma db push:
CREATE UNIQUE INDEX short_links_post_id_original_url_active_idx
ON short_links (post_id, original_url)
WHERE revoked_at IS NULL;
This index lets you create new short links for the same content after revoking the old one, while preventing duplicate active links. It's the kind of constraint that catches real bugs, like a race condition where two requests create the same link at the same time.
Slug Generation
The slug is the short part of the short link. It needs to be unique, unpredictable (to prevent enumeration), and URL-safe. I generate 7-character base62 strings using the Web Crypto API.
const CHARSET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
export function generateSlug(length = 7): string {
const bytes = new Uint8Array(length)
crypto.getRandomValues(bytes)
return Array.from(bytes)
.map((byte) => CHARSET[byte % CHARSET.length])
.join('')
}
Why 7 characters? With 62 possible characters, 7 gives you 62^7 (about 3.5 trillion) possible slugs. That's more than enough for any realistic short-link service, and the character set (alphanumeric) is safe in any URL path segment.
The crypto.getRandomValues() call is important. It uses the operating system's cryptographic random number generator, which produces uniformly distributed, unpredictable values. Math.random() is not suitable because it's predictable and not uniformly distributed across the full range of integers.
Using byte % 62 introduces a slight bias (since 256 / 62 is not an integer), but for a 7-character slug, the bias is negligible. If you need perfect uniformity, you can use rejection sampling to discard bytes in the biased range.
Handling Slug Collisions
Even with 3.5 trillion possible slugs, there's a chance of collision. The @unique constraint on the slug column catches it at the database level. The create action retries up to 5 times before giving up.
import { generateSlug } from '@/lib/shortLink/slugGenerator'
const MAX_RETRIES = 5
export async function createShortLink(input: {
originalUrl: string
postId?: string
}) {
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
const slug = generateSlug()
try {
const shortLink = await prisma.shortLink.create({
data: {
slug,
originalUrl: input.originalUrl,
postId: input.postId,
},
})
return shortLink
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
continue // Unique constraint violation, try again
}
}
throw error
}
}
throw new Error('Failed to generate unique slug after 5 attempts')
}
In practice, collisions are extremely rare with 7-character base62 slugs. The birthday paradox puts the 50% collision probability at around 1.3 billion links, which is far beyond what most applications need. The retry logic is a safety net, not something you'll hit regularly.
IP Hashing for Click Tracking
When someone clicks a short link, you want to count the click but not store the raw IP address. Storing IP addresses raises privacy concerns and GDPR compliance questions. Instead, you hash the IP with a secret salt and store only the hash.
export function hashIp(ip: string): string {
const salt = process.env.SHORT_LINK_IP_SALT
const encoder = new TextEncoder()
const key = crypto.subtle.importKey(
'raw',
encoder.encode(salt),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
)
return crypto.subtle
.sign('HMAC', key, encoder.encode(ip))
.then((signature) => {
const hashArray = new Uint8Array(signature)
// Take first 16 hex chars (64 bits) — enough for deduplication
return Array.from(hashArray)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
.slice(0, 16)
})
}
HMAC-SHA256 with a secret salt means the same IP always produces the same hash, so you can count unique visitors without reversing the hash back to an IP. Truncating to 16 hex characters (64 bits) is enough for deduplication across realistic click volumes while keeping the stored value compact.
The SHORT_LINK_IP_SALT is critical. If someone gets access to the hashes and knows the algorithm, they could try to brute-force IPs against the hashes. The salt makes this infeasible. Store it as an environment variable, not in code, and rotate it periodically if you want to invalidate old hashes.
The Server Action
The createShortLink action is a Next.js server action. It validates the input, handles collision retries, and returns the full short link URL.
'use server'
import { createShortLink } from '@/lib/actions/shortLink/createShortLink'
export async function createShortLinkAction(formData: FormData) {
const originalUrl = formData.get('originalUrl') as string
const postId = formData.get('postId') as string | undefined
const shortLink = await createShortLink({
originalUrl,
postId,
})
return `${process.env.NEXT_PUBLIC_APP_URL}/s/${shortLink.slug}`
}
The redirect endpoint (not shown here) looks up the slug, checks that revokedAt is null, records a click event with the hashed IP, and redirects to the original URL. The click recording happens asynchronously so the redirect isn't blocked by a slow database write.
Testing
The slug generator and IP hasher are pure functions, which makes them easy to test in isolation:
test('generates slug of correct length', () => {
const slug = generateSlug(7)
expect(slug).toHaveLength(7)
expect(slug).toMatch(/^[0-9a-zA-Z]+$/)
})
test('generates unique slugs across calls', () => {
const slugs = new Set(Array.from({ length: 100 }, () => generateSlug()))
expect(slugs.size).toBe(100)
})
test('hashes the same IP consistently', async () => {
process.env.SHORT_LINK_IP_SALT = 'test-salt'
const hash1 = await hashIp('192.168.1.1')
const hash2 = await hashIp('192.168.1.1')
expect(hash1).toBe(hash2)
expect(hash1).toHaveLength(16)
})
test('produces different hashes for different IPs', async () => {
process.env.SHORT_LINK_IP_SALT = 'test-salt'
const hash1 = await hashIp('192.168.1.1')
const hash2 = await hashIp('10.0.0.1')
expect(hash1).not.toBe(hash2)
})
For the createShortLink action, integration tests verify the happy path (creating a link) and the collision retry logic. The test database uses the same Prisma schema with the partial unique index applied via a migration.
Conclusion
A URL shortener's core is three pieces: a data model that handles deduplication and revocation, a slug generator that produces unpredictable short strings, and a privacy-preserving click tracker. The partial unique index on (postId, originalUrl) WHERE revoked_at IS NULL prevents active duplicates while allowing new links after revocation. HMAC-SHA256 IP hashing gives you unique visitor counts without storing personal data. And the collision retry logic in createShortLink is a simple safety net against the extremely unlikely case of a duplicate 7-character slug.
Newsletter
A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.