# How to Build URL shortener app with V0

- Tool: v0
- Difficulty: Beginner
- Compatibility: V0 Free
- Last updated: April 2026

## TL;DR

Build a Bitly-style URL shortener with V0 featuring short link creation, click tracking with analytics (referrer, country, device), and a dashboard with Recharts visualizations. You'll create nanoid-based short codes, 302 redirect handling, and per-link analytics — all in about 30-60 minutes.

## Before you start

- A V0 account (free tier works for this project)
- A Supabase project (free tier works — connect via V0's Connect panel)
- No additional API keys or accounts needed

## Step-by-step guide

### 1. Set up the links and clicks database schema

Open V0 and create a new project. Use the Connect panel to add Supabase. Create the links and clicks tables with a unique constraint on short_code for collision prevention.

```
// Paste this prompt into V0's AI chat:
// Create a Supabase schema for a URL shortener:
// 1. links table: id (uuid PK), short_code (text UNIQUE NOT NULL), original_url (text NOT NULL), owner_id (uuid FK nullable), title (text), clicks (int DEFAULT 0), created_at (timestamptz), expires_at (timestamptz nullable)
// 2. clicks table: id (uuid PK), link_id (uuid FK), referrer (text), country (text), city (text), device (text), browser (text), ip_address (inet), clicked_at (timestamptz DEFAULT now())
// Add index on links.short_code for fast lookups.
// RLS: public can read links for redirect, authenticated users manage their own links.
// Generate the SQL migration.
```

> Pro tip: Use V0's prompt queuing — queue the schema prompt, then the URL form component, then the analytics dashboard as three separate prompts.

**Expected result:** Two tables created with a unique index on short_code and RLS policies for public redirect access and authenticated management.

### 2. Build the URL shortening form with nanoid generation

Create the main page with an Input for the URL and a Button to shorten it. The Server Action generates a 7-character nanoid short code and retries on collision.

```
'use server'

import { createClient } from '@/lib/supabase/server'
import { nanoid } from 'nanoid'
import { revalidatePath } from 'next/cache'

export async function shortenUrl(formData: FormData) {
  const supabase = await createClient()
  const originalUrl = formData.get('url') as string
  const title = formData.get('title') as string

  let shortCode = nanoid(7)
  let attempts = 0

  while (attempts < 5) {
    const { error } = await supabase.from('links').insert({
      short_code: shortCode,
      original_url: originalUrl,
      title: title || null,
    })

    if (!error) {
      revalidatePath('/')
      return { shortCode }
    }

    if (error.code === '23505') {
      shortCode = nanoid(7)
      attempts++
    } else {
      return { error: error.message }
    }
  }

  return { error: 'Failed to generate unique code' }
}
```

**Expected result:** Submitting a URL generates a 7-character short code. If a collision occurs (extremely rare with nanoid), it retries up to 5 times.

### 3. Create the redirect handler with click tracking

Build an API route that looks up the short code, logs click metadata, increments the click counter, and returns a 302 redirect to the original URL.

```
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function GET(
  req: NextRequest,
  { params }: { params: Promise<{ code: string }> }
) {
  const { code } = await params

  const { data: link } = await supabase
    .from('links')
    .select('id, original_url, expires_at')
    .eq('short_code', code)
    .single()

  if (!link) {
    return NextResponse.redirect(new URL('/', req.url))
  }

  if (link.expires_at && new Date(link.expires_at) < new Date()) {
    return NextResponse.redirect(new URL('/?expired=true', req.url))
  }

  // Log click asynchronously
  const userAgent = req.headers.get('user-agent') ?? ''
  const referrer = req.headers.get('referer') ?? ''
  const ip = req.headers.get('x-forwarded-for')?.split(',')[0] ?? ''

  supabase.from('clicks').insert({
    link_id: link.id,
    referrer,
    device: /mobile/i.test(userAgent) ? 'mobile' : 'desktop',
    browser: /chrome/i.test(userAgent) ? 'Chrome' : /firefox/i.test(userAgent) ? 'Firefox' : /safari/i.test(userAgent) ? 'Safari' : 'Other',
    ip_address: ip,
  }).then(() => {
    supabase.rpc('increment_clicks', { p_link_id: link.id })
  })

  return NextResponse.redirect(link.original_url, { status: 302 })
}
```

**Expected result:** Visiting /api/redirect/abc1234 logs the click and redirects to the original URL with a 302 status. Expired links redirect to the home page.

### 4. Build the analytics dashboard with Recharts

Create a per-link analytics page showing click trends over time, device breakdown, and referrer sources using Recharts BarChart and PieChart.

```
// Paste this prompt into V0's AI chat:
// Build a link analytics page at app/links/[id]/page.tsx with:
// 1. Server Component that fetches the link and all its clicks from Supabase
// 2. Summary Cards: total clicks, unique visitors (distinct IP), top referrer
// 3. Recharts BarChart showing clicks per day for the last 30 days
// 4. Recharts PieChart showing device breakdown (mobile vs desktop)
// 5. Table showing recent clicks with columns: timestamp, referrer, device, browser, country
// 6. Badge for active/expired status
// 7. Copy button to copy the short URL
// Use shadcn/ui Card for chart containers and Table for click log.
```

**Expected result:** An analytics page with summary Cards, a BarChart of clicks over time, PieChart of device breakdown, and a Table of recent individual clicks.

### 5. Build the link management dashboard

Create a dashboard page showing all the user's shortened links in a Table with click counts, creation dates, and action buttons for copying, viewing analytics, and deleting.

```
// Paste this prompt into V0's AI chat:
// Build a link management dashboard at app/dashboard/page.tsx with:
// 1. Server Component fetching all links for the current user
// 2. shadcn/ui Table with columns: Short URL (clickable), Original URL (truncated), Clicks, Created, Status Badge (active/expired)
// 3. Each row has a copy button (copies short URL to clipboard) and a link to analytics
// 4. "Shorten URL" Button at the top that opens a Dialog with Input for URL and optional title
// 5. Delete Button with AlertDialog confirmation
// 6. Summary Cards at top: total links, total clicks, clicks today
// Use Badge variant 'default' for active links and 'destructive' for expired.
```

**Expected result:** A dashboard Table showing all shortened links with click counts, status Badges, copy buttons, analytics links, and summary Cards at the top.

## Complete code example

File: `app/api/redirect/[code]/route.ts`

```typescript
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function GET(
  req: NextRequest,
  { params }: { params: Promise<{ code: string }> }
) {
  const { code } = await params

  const { data: link } = await supabase
    .from('links')
    .select('id, original_url, expires_at')
    .eq('short_code', code)
    .single()

  if (!link) {
    return NextResponse.redirect(new URL('/', req.url))
  }

  if (link.expires_at && new Date(link.expires_at) < new Date()) {
    return NextResponse.redirect(new URL('/?expired=true', req.url))
  }

  const userAgent = req.headers.get('user-agent') ?? ''
  const referrer = req.headers.get('referer') ?? ''

  supabase
    .from('clicks')
    .insert({
      link_id: link.id,
      referrer,
      device: /mobile/i.test(userAgent) ? 'mobile' : 'desktop',
      browser: /chrome/i.test(userAgent)
        ? 'Chrome'
        : /safari/i.test(userAgent)
          ? 'Safari'
          : 'Other',
    })
    .then(() =>
      supabase.rpc('increment_clicks', { p_link_id: link.id })
    )

  return NextResponse.redirect(link.original_url, { status: 302 })
}
```

## Common mistakes

- **Using sequential IDs or short random strings for short codes** — Sequential IDs (1, 2, 3) are guessable — anyone can enumerate all your links. Short random strings (3-4 chars) have high collision probability at scale. Fix: Use nanoid(7) which generates URL-safe random strings with 2B+ combinations. Retry on unique constraint violation to handle the rare collision.
- **Logging clicks synchronously before redirecting** — Waiting for the database insert to complete before redirecting adds 50-200ms latency to every click. Users notice the delay. Fix: Fire the click insert as a fire-and-forget promise. The redirect returns immediately while the click logs asynchronously.
- **Not handling expired links** — Expired links that still redirect create confusion and can point to outdated or harmful content. Fix: Check expires_at in the redirect handler and redirect expired links to a friendly 'link expired' page instead of the original URL.

## Best practices

- Use nanoid(7) for short code generation — URL-safe, collision-resistant, and compact enough for short URLs
- Log clicks asynchronously (fire-and-forget) so the redirect response is instant without database latency
- Use V0's prompt queuing to build the URL form, redirect handler, and analytics dashboard as three queued prompts
- Create an atomic increment_clicks RPC function in Supabase to prevent count drift from concurrent clicks
- Use Design Mode (Option+D) to visually polish the URL form and analytics charts at zero credit cost
- Set up a custom domain in Vercel project settings for branded short links (e.g., yourbrand.link/abc1234)
- Store the short link domain as an environment variable so it works correctly in both development and production

## Frequently asked questions

### How are short codes generated?

Short codes are generated using nanoid(7), which creates 7-character URL-safe random strings. With 2 billion possible combinations, collisions are extremely rare. If a collision occurs, the Server Action retries with a new nanoid up to 5 times.

### Can I use a custom domain for short links?

Yes. Add a custom domain in your Vercel project settings (e.g., yourbrand.link). Then set NEXT_PUBLIC_SHORT_DOMAIN in V0's Vars tab so the UI displays the correct short URL.

### What V0 plan do I need?

V0 Free tier works perfectly. The URL shortener is a simple project with basic Server Components, one API route, and shadcn/ui components.

### How accurate are the analytics?

Click tracking captures referrer, user agent (parsed into device and browser), and IP address. Country and city detection requires a third-party IP geolocation API which can be added as an enhancement.

### How do I deploy this?

Click Share > Publish in V0. The Supabase connection is auto-configured. Short links start working immediately with the Vercel production URL.

### Can RapidDev help build a custom link management platform?

Yes. RapidDev has built 600+ apps including marketing platforms with link tracking, UTM management, and conversion analytics. Book a free consultation to discuss your link management needs.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/url-shortener-app
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/url-shortener-app
