Building itty-bitty-link: A Deep Dive into Vercel's Developer Experience
June 26, 2026
Building itty-bitty-link: A Deep Dive into Vercel's Developer Experience
When you're learning a new platform, sometimes the best approach is to build something concrete. That's exactly what I did with itty-bitty-link — a deliberately scoped link shortener designed as a test bed for exploring Vercel's tooling and developer experience.
This isn't a production SaaS ready for millions of users. Instead, it's a learning vehicle that demonstrates modern full-stack patterns, serverless best practices, and how to leverage Vercel's platform effectively. In this post, I'll walk through the technical architecture, key design decisions, and most importantly, what Vercel gets right (and where there's room for improvement).
Project Overview: Intentional Constraints
itty-bitty-link is deliberately simple:
- Core Feature: Users sign up, create shortened links (5-link limit), and share them
- Tech Stack: Next.js 14 (App Router), TypeScript, Prisma, PostgreSQL (Neon), NextAuth.js v5, Tailwind CSS
- Deployment: Vercel (naturally)
- Live Demo: https://itty-bitty-link.vercel.app/
The 5-link limit is intentional—it's a growth constraint pattern. Rather than building a free tier with unlimited links, this enforces a clear upgrade path for future monetisation. It also simplifies the scope: I'm testing Vercel's platform, not scaling it to production traffic.
The feature set is minimal but complete:
- Email/password and Google OAuth authentication
- Link creation with automatic alias generation
- Dashboard showing user's shortened links
- Copy-to-clipboard functionality
- Redirect endpoint (
/r/[id]) that resolves aliases to full URLs
Nothing fancy. Everything purposeful.
Part 1: Vercel Platform Deep Dive
This is where things get interesting. Vercel isn't just hosting—it's a developer experience platform. Let's explore how itty-bitty-link leverages (and tests) Vercel's key features.
Configuration as Code: The vercel.json Approach
The vercel.json file is where you define Vercel-specific behaviour. Here's what I configured:
{
"functions": {
"src/app/api/**/*.ts": {
"maxDuration": 30,
"memory": 1024
}
},
"regions": ["iad1"],
"headers": [
{
"source": "/r/:id",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=3600, stale-while-revalidate=604800"
}
]
},
{
"source": "/(.*)",
"headers": [
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-Frame-Options",
"value": "SAMEORIGIN"
},
{
"key": "X-XSS-Protection",
"value": "1; mode=block"
}
]
}
]
}
What each section does:
-
functions: API routes get 30-second timeouts and 1GB memory. This is perfect for database queries and server-side logic, but wouldn't work for long-running batch jobs (hence why Edge Runtime isn't used here). -
regions: Fixed toiad1(US East, Washington D.C.). For a global audience, you'd use multiple regions or let Vercel auto-select based on traffic. -
headers: Two critical configurations:- Redirect caching:
/r/:idendpoints cache for 1 hour with 7-day stale-while-revalidate. This means clicks are incredibly fast (edge-cached), and the stale revalidation window gives you flexibility with database updates. - Security headers: Standard X-Frame-Options (prevent clickjacking), X-Content-Type-Options (prevent MIME sniffing), and XSS protection.
- Redirect caching:
The caching strategy deserves explanation. When someone clicks a shortened link, it hits the redirect endpoint:
// src/app/r/[id]/page.tsx
export const revalidate = 3600; // 1 hour
export default async function RedirectPage({
params: { id },
}: {
params: { id: string };
}) {
const linkAlias = await prisma.linkAlias.findUnique({
where: { alias: id },
include: { links: { select: { url: true } } },
});
if (!linkAlias) {
notFound();
}
redirect(linkAlias.links[0].url);
}
This is static generation with revalidation. The first request to /r/abc123 is dynamically generated, but then cached for 3,600 seconds. Vercel's edge network serves subsequent requests instantly, and after the cache expires, it auto-regenerates in the background. Combined with stale-while-revalidate, you get exceptional performance for read-heavy workloads (which link redirects are).
Serverless Optimisations: The next.config.mjs Story
Here's the Next.js configuration:
// next.config.mjs
const nextConfig = {
output: "standalone",
compress: true,
generateEtags: true,
images: {
formats: ["image/avif", "image/webp"],
remotePatterns: [
{
protocol: "https",
hostname: "**",
},
],
},
};
export default nextConfig;
Key decisions:
-
output: "standalone": This generates a minimal.nextfolder that only includes necessary files. In production, this significantly reduces cold start times and Docker image size on Vercel. The standalone build is optimised for serverless environments where every millisecond matters. -
compress: true: Vercel compresses responses by default, but explicit configuration ensures it's prioritised. This is especially important for initial page loads and API responses. -
generateEtags: true: ETags enable efficient caching. Vercel can serve stale content if the ETag matches, reducing bandwidth for unchanged assets. -
images: Modern format support (AVIF, WebP) and allowlisting all HTTPS hostnames. If you're using external image services (like profile pictures from OAuth providers), this ensures they're optimised on-the-fly.
Environment Management and the Neon Integration
Vercel's environment variable system is surprisingly sophisticated. Here's what I use:
# .env.example
DATABASE_URL=postgresql://user:password@host/db?sslmode=require
POSTGRES_PRISMA_URL=postgresql://...?schema=public
POSTGRES_URL_NON_POOLING=postgresql://...
AUTH_SECRET=your-secret-here
GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=xxx
The Neon PostgreSQL setup is interesting. Neon provides:
POSTGRES_PRISMA_URL: Connection pooler (PgBouncer). Use this for Prisma in production serverless—prevents connection pool exhaustion.POSTGRES_URL_NON_POOLING: Direct connection. Use this for long-lived connections or migrations.
Vercel's UI for managing these is clean—each environment (Production, Preview, Development) can have different values. Preview deployments automatically pull from Preview environment variables, which is crucial for testing new database schemas without affecting production data.
Pro tip: I implemented a Prisma singleton pattern to prevent connection pool exhaustion during development:
// src/utils/prisma.ts
const prismaClientSingleton = () => {
return new PrismaClient();
};
declare global {
var prisma: undefined | ReturnType<typeof prismaClientSingleton>;
}
const prisma = global.prisma ?? prismaClientSingleton();
export default prisma;
if (process.env.NODE_ENV !== "production") global.prisma = prisma;
In development, this reuses the same Prisma instance across hot module reloads. In production, each serverless function gets its own connection, which Vercel scales transparently.
Runtime Considerations: Node.js vs. Edge
You might wonder: why not use Vercel's Edge Runtime for ultra-fast performance?
Simple answer: Prisma doesn't support Edge Runtime yet. Prisma is CPU-bound and requires full Node.js APIs. Edge Runtime is optimised for lightweight, stateless logic (authentication middleware, request routing, etc.).
So I chose Node.js runtime for all API routes and server actions. This means:
- Slightly slower cold starts (vs. Edge), but manageable with Vercel's optimisations
- Full access to database queries and CPU-intensive operations
- Compatibility with the entire npm ecosystem
For a production link shortener serving millions of requests, you'd potentially split concerns:
- Edge: Redirect logic (purely reading cache)
- Node.js: Link creation, analytics, database operations
But that's premature optimisation for this learning project.
Part 2: Technical Architecture
The Database Schema: A Smart Deduplication Pattern
Here's where architectural decisions make or break scalability:
// prisma/schema.prisma (simplified)
model User {
id String @id @default(cuid())
email String @unique
password String?
name String?
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
links Link[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Link {
id String @id @default(cuid())
url String
title String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
linkAliasId String
linkAlias LinkAlias @relation(fields: [linkAliasId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model LinkAlias {
id String @id @default(cuid())
alias String @unique
url String
links Link[]
createdAt DateTime @default(now())
}
The insight: LinkAlias is separate from Link.
Why? Because multiple users might shorten the same URL (e.g., https://github.com). Without deduplication, you'd waste aliases and complicate analytics. Instead:
- User A shortens
https://github.com→ gets aliasabc123 - User B also shortens
https://github.com→ reuses aliasabc123 - Both users' links point to the same
LinkAliasrecord
This pattern scales beautifully:
- Reduced database bloat (one alias per unique URL, not per user)
- Simplified redirect logic (lookup by alias, not by user + URL)
- Built-in analytics ready (count links per alias = click tracking)
NextAuth.js v5: Dual Authentication
Authentication is security-critical, and NextAuth.js v5 abstracts away the complexity:
// src/app/auth.ts
import { PrismaAdapter } from "@auth/prisma-adapter";
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import Google from "next-auth/providers/google";
import { compare } from "bcryptjs";
import { z } from "zod";
import prisma from "@/utils/prisma";
export const { auth, handlers, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 days
updateAge: 24 * 60 * 60, // 24 hours
},
providers: [
Credentials({
async authorize(credentials) {
const parsed = z
.object({
email: z.string().email(),
password: z.string().min(6),
})
.safeParse(credentials);
if (!parsed.success) return null;
const user = await prisma.user.findUnique({
where: { email: parsed.data.email.toLowerCase() },
});
if (!user || !user.password) return null;
const isValid = await compare(parsed.data.password, user.password);
if (!isValid) return null;
return { id: user.id, email: user.email, name: user.name };
},
}),
Google({
allowDangerousEmailAccountLinking: true,
}),
],
callbacks: {
jwt({ token, user }) {
if (user) token.userId = user.id;
return token;
},
session({ session, token }) {
if (session.user) {
session.user.userId = token.userId as string;
}
return session;
},
},
});
Key architectural choices:
-
Dual providers: Users can sign up with email/password OR Google OAuth. The
allowDangerousEmailAccountLinkingflag lets users link both methods to one account. -
JWT sessions: Stateless tokens mean Vercel's distributed serverless functions don't need shared session storage. The token includes the
userId, so any function can validate it without database queries. -
Email normalisation:
toLowerCase()prevents duplicate accounts (e.g.,test@example.comvs.Test@Example.com). -
bcryptjs for password hashing: 10 rounds of hashing means even brute-force attempts are computationally expensive.
Server Actions vs. API Routes: When to Use Each
The app uses both patterns strategically:
Server Actions for mutations (link creation, deletion):
// src/app/actions/links.ts
"use server";
export async function handleCreate(formData: FormData) {
const session = await auth();
if (!session?.user?.userId) {
redirect("/login");
}
const parsed = createLinkSchema.safeParse({
title: formData.get("title"),
url: formData.get("url"),
});
if (!parsed.success) {
return { error: "Invalid input" };
}
// Check 5-link limit
const linkCount = await prisma.link.count({
where: { userId: session.user.userId },
});
if (linkCount >= 5) {
return { error: "You've reached your 5-link limit" };
}
try {
// Check if URL already has an alias (deduplication)
let linkAlias = await prisma.linkAlias.findUnique({
where: { url: parsed.data.url },
});
// Create new alias if needed
if (!linkAlias) {
let alias = generateAlias();
// Handle collisions (recursive)
while (
await prisma.linkAlias.findUnique({ where: { alias } })
) {
alias = generateAlias();
}
linkAlias = await prisma.linkAlias.create({
data: { alias, url: parsed.data.url },
});
}
// Create link associated with user and alias
await prisma.link.create({
data: {
title: parsed.data.title,
url: parsed.data.url,
userId: session.user.userId,
linkAliasId: linkAlias.id,
},
});
revalidatePath("/dashboard");
redirect("/dashboard");
} catch (error) {
return { error: "Failed to create link" };
}
}
API routes for data fetching:
// src/app/api/links/route.ts
export async function GET(request: NextRequest) {
const session = await auth();
if (!session?.user?.userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const page = parseInt(request.nextUrl.searchParams.get("page") || "0");
const PAGE_SIZE = 10;
const [links, total] = await Promise.all([
prisma.link.findMany({
where: { userId: session.user.userId },
include: { linkAlias: true },
orderBy: { createdAt: "asc" },
skip: page * PAGE_SIZE,
take: PAGE_SIZE,
}),
prisma.link.count({
where: { userId: session.user.userId },
}),
]);
return NextResponse.json({
links,
pages: Math.ceil(total / PAGE_SIZE),
total,
});
}
When to use what:
- Server Actions: Simple mutations, form submissions, when you want automatic loading states
- API Routes: Complex data fetching, webhooks, when you need explicit request/response control
Server Actions are simpler and require less boilerplate, so I favour them by default.
Part 3: Key Implementation Details
Alias Generation with Collision Handling
function generateAlias(): string {
return Math.random()
.toString(36)
.substring(2, 8);
}
This generates random 6-character strings from a-z0-9. With 36^6 ≈ 2.2 billion possible values, collisions are rare. But the app handles them anyway:
while (await prisma.linkAlias.findUnique({ where: { alias } })) {
alias = generateAlias();
}
If a collision occurs (exceedingly unlikely), the code regenerates. In production with millions of links, you might want a smarter approach:
- Increment length to 7 or 8 characters
- Use a custom alphabet (base62 instead of base36)
- Batch-generate and reserve aliases
But for a 5-link-per-user app? Recursive regeneration is fine.
The 5-Link Limit: Growth Constraint as UX
const linkCount = await prisma.link.count({
where: { userId: session.user.userId },
});
if (linkCount >= 5) {
return { error: "You've reached your 5-link limit" };
}
This is enforced both in the server action and on the UI (dashboard shows "5/5 links"). It's a deliberate constraint:
User experience: Instead of confusing users with a "free tier" and upsell, the limit is obvious and immediate. Users hit it quickly and understand the upgrade path.
Business viability: This makes the monetisation story clear. The future paid tier removes the limit.
Development focus: I'm not worried about storage, indexing, or scaling. Focus stays on learning Vercel.
Client-Side Data Fetching with SWR
// src/app/dashboard/LinksList.tsx
"use client";
import useSWR from "swr";
export default function LinksList() {
const { data, error, isLoading } = useSWR(
"/api/links?page=0",
async (url) => {
const res = await fetch(url);
return res.json();
}
);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error loading links</div>;
return (
<div>
{data?.links.map((link) => (
<LinkItem key={link.id} link={link} />
))}
</div>
);
}
SWR (stale-while-revalidate) is perfect for this:
- Reuses cached data while fetching fresh data in background
- Automatic error handling and retry
- Minimal setup compared to React Query
- Built-in mutation support for optimistic updates
For a dashboard with frequently-updated content, SWR provides a smooth UX without verbose configuration.
Security Best Practices
- Email normalisation: Prevents case-sensitivity attacks
- Bcryptjs: Industry-standard password hashing
- Zod validation: Type-safe input validation prevents injection attacks
- Session-based authorization: Every protected route checks
const session = await auth() - HTTP security headers: X-Frame-Options, X-Content-Type-Options (via Vercel config)
- HTTPS enforcement: Vercel uses HTTPS by default; no configuration needed
Part 4: Lessons Learned from Vercel
What Vercel Gets Right
1. Environment Management Vercel's UI for managing environment variables across Production/Preview/Development is exceptional. One-click deployments with different configs? Chef's kiss.
2. Preview Deployments Every push to a non-main branch creates a preview URL. This means reviewing PRs with actual production-like conditions, not just local builds.
3. Deployment Logs and Monitoring The deployment logs are detailed and searchable. Function execution times, environment variable availability, build times—all visible. No guessing.
4. Zero-Config Defaults
You almost never edit vercel.json. Vercel just works. Deployments are typically one command (vercel deploy).
5. Image Optimisation Automatic AVIF/WebP conversion for all external images. Load times plummet with zero configuration.
Room for Improvement
1. Custom Domains Setting up custom domains requires extra steps. Other platforms make this truly one-click.
2. Edge Runtime Maturity Edge Runtime is powerful but has significant limitations (no Node.js libs, no database access). Prisma support would unlock more use cases.
3. Cost Transparency Pricing is consumption-based, but it's hard to estimate costs for new projects. A cost calculator based on expected traffic would help.
4. Function Logs Retention Logs disappear after 24 hours. For debugging production issues later, longer retention (paid feature?) would be valuable.
Part 5: Future Enhancements for Advanced Learning
Monetisation Path: Stripe Integration
// Future feature: Upgrade to paid tier
const PLANS = {
free: {
linkLimit: 5,
customDomain: false,
analytics: false,
},
pro: {
linkLimit: null, // Unlimited
customDomain: true,
analytics: true,
cost: 9.99, // /month
},
};
Implementation would involve:
- Stripe API integration for subscription management
- Database schema for
UserPlanandSubscription - Webhook handlers for subscription events (upgrade, cancel, renewal)
- Checkout flow (redirect to Stripe, handle return)
- Feature gating logic in server actions
This teaches state-of-the-art SaaS patterns: billing, subscriptions, and feature toggles.
Advanced Features
Custom Domains
Instead of itty-bitty-link.vercel.app/r/abc123, users could use mycompany.com/abc123. Requires:
- DNS validation (CNAME or TXT record)
- Dynamic route resolution in middleware
- Per-link redirect logic
QR Code Generation
Every shortened link gets a QR code. Libraries like qrcode.react make this trivial:
import QRCode from "qrcode.react";
export function LinkQR({ alias }: { alias: string }) {
const url = `${process.env.NEXT_PUBLIC_SITE_URL}/r/${alias}`;
return <QRCode value={url} />;
}
Link Expiration Links can expire after X days:
model Link {
// ... existing fields
expiresAt DateTime?
}
// In redirect endpoint:
if (linkAlias.expiresAt && linkAlias.expiresAt < now()) {
return notFound();
}
Password Protection Shortened links can require a password before revealing the target:
model Link {
// ... existing fields
password String? // bcrypt hash
}
// UI: Show password form, verify before redirect
Analytics Dashboard Track clicks, geographic origin, referrer:
model LinkClick {
id String @id @default(cuid())
linkAliasId String
linkAlias LinkAlias @relation(fields: [linkAliasId], references: [id])
ipAddress String?
country String?
referer String?
userAgent String?
createdAt DateTime @default(now())
}
// On redirect:
await prisma.linkClick.create({
data: {
linkAliasId: linkAlias.id,
ipAddress: request.headers.get("x-forwarded-for"),
referer: request.headers.get("referer"),
},
});
Webhook API External services subscribe to link creation/deletion:
// POST /api/webhooks/:id/links
// Body: { event: "link.created", link: { ... } }
Rate Limiting Prevent abuse with Redis-backed rate limiting:
import { Ratelimit } from "@upstash/ratelimit";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(100, "10 s"),
});
const { success } = await ratelimit.limit(userId);
if (!success) {
return new Response("Too many requests", { status: 429 });
}
Each feature teaches specific advanced concepts: custom domains (DNS + middleware), analytics (time-series data), monetisation (Stripe), APIs (webhooks), and scaling (rate limiting).
Conclusion: Learning Through Building
itty-bitty-link is a deceptively simple project with sophisticated architecture underneath. By building on Vercel, I've explored:
- How Vercel's deployment model shapes application design
- Modern full-stack patterns with Next.js 14
- Authentication best practices
- Scalable database design
- Serverless performance optimisation
The app is deliberately scoped to be learnable in a weekend, but extensible enough to keep learning. Every future enhancement (custom domains, analytics, monetisation) teaches a new advanced topic.
If you're learning Vercel, Next.js, or full-stack TypeScript development, the codebase is designed to be accessible. The patterns used here (Server Actions, Prisma, NextAuth, Tailwind) are production-standard and applicable to real projects.
Try it out at https://itty-bitty-link.vercel.app/, create a few shortened links, and imagine the possibilities. Want to extend it? Clone it, add a feature, and deploy to Vercel in seconds.
That's the beauty of the modern full-stack development workflow.
Want to learn more? Check out: