← Back to Blog

Building itty-bitty-link: A Deep Dive into Vercel's Developer Experience

June 26, 2026

nextjsvercelsaasserverlessprismafullstack

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:

  1. 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).

  2. regions: Fixed to iad1 (US East, Washington D.C.). For a global audience, you'd use multiple regions or let Vercel auto-select based on traffic.

  3. headers: Two critical configurations:

    • Redirect caching: /r/:id endpoints 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.

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:

  1. output: "standalone": This generates a minimal .next folder 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.

  2. 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.

  3. generateEtags: true: ETags enable efficient caching. Vercel can serve stale content if the ETag matches, reducing bandwidth for unchanged assets.

  4. 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 alias abc123
  • User B also shortens https://github.com → reuses alias abc123
  • Both users' links point to the same LinkAlias record

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:

  1. Dual providers: Users can sign up with email/password OR Google OAuth. The allowDangerousEmailAccountLinking flag lets users link both methods to one account.

  2. 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.

  3. Email normalisation: toLowerCase() prevents duplicate accounts (e.g., test@example.com vs. Test@Example.com).

  4. 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

  1. Email normalisation: Prevents case-sensitivity attacks
  2. Bcryptjs: Industry-standard password hashing
  3. Zod validation: Type-safe input validation prevents injection attacks
  4. Session-based authorization: Every protected route checks const session = await auth()
  5. HTTP security headers: X-Frame-Options, X-Content-Type-Options (via Vercel config)
  6. 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 UserPlan and Subscription
  • 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: