# How to Build a URL Shortener App with Lovable

- Tool: How to Build with Lovable
- Difficulty: Beginner
- Compatibility: Lovable free tier or higher
- Last updated: April 2026

## TL;DR

Build a URL shortener in Lovable with a Supabase links table, a redirect Edge Function that logs clicks asynchronously, expirable links, and a click analytics chart. Users paste a long URL, get a short code, and share the short link — all backed by Supabase with no external services required.

## Before you start

- Lovable account (free tier is sufficient for this project)
- Supabase project (free tier works)
- Supabase URL and anon key saved as VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY
- Basic familiarity with how URL redirects work

## Step-by-step guide

### 1. Create the links and clicks database schema

Prompt Lovable to create the two tables and the short code generation function. The RLS setup ensures users can only manage their own links while the redirect Edge Function uses the service role to look up any link.

```
Create a URL shortener schema in Supabase:

Tables:
- links: id, user_id (references auth.users), short_code (text, unique), target_url (text), title (text, optional), expires_at (timestamptz, nullable), is_active (bool default true), click_count (int default 0), created_at
- link_clicks: id, link_id, clicked_at, referrer (text, nullable), country (text, nullable), user_agent_hint (text, nullable)

RLS:
- links: users can SELECT/INSERT/UPDATE/DELETE their own rows (user_id = auth.uid()). Service role can SELECT all (for redirect function).
- link_clicks: service role only for INSERT and SELECT. Users can SELECT clicks for their own links via a view: CREATE VIEW my_link_clicks AS SELECT lc.* FROM link_clicks lc JOIN links l ON lc.link_id = l.id WHERE l.user_id = auth.uid().

SQL function generate_short_code() RETURNS text:
- Generates a random 6-char code from characters [a-z0-9]
- Uses a loop with up to 3 retries if the code already exists in the links table
- Returns the unique code

SQL function increment_click_count(p_link_id uuid) RETURNS void:
- UPDATE links SET click_count = click_count + 1 WHERE id = p_link_id

Add index: CREATE INDEX idx_links_short_code ON links(short_code);
Add index: CREATE INDEX idx_clicks_link_date ON link_clicks(link_id, clicked_at DESC);
```

> Pro tip: Ask Lovable to also create a Supabase view links_with_recent_clicks that joins links with the count of link_clicks in the last 30 days. Use this view in the dashboard to show both total and recent click counts without a subquery on every row.

**Expected result:** Tables are created with RLS policies and indexes. The generate_short_code() function is ready. The my_link_clicks view is created. TypeScript types are generated.

### 2. Build the redirect Edge Function

Create the Edge Function that handles redirect requests. It reads the short code from the URL, looks up the target, logs the click asynchronously, and returns the 301 redirect. Speed is the priority — the response fires before the analytics INSERT completes.

```
// supabase/functions/redirect/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

serve(async (req: Request) => {
  const url = new URL(req.url)
  const code = url.searchParams.get('code') ?? url.pathname.split('/').pop()

  if (!code) {
    return new Response('Missing short code', { status: 400 })
  }

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

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

  if (!link || !link.is_active) {
    return new Response('Link not found', { status: 404 })
  }

  if (link.expires_at && new Date(link.expires_at) < new Date()) {
    return new Response('Link has expired', { status: 410 })
  }

  // Log click asynchronously — do not await
  const referrer = req.headers.get('referer') ?? null
  const country = req.headers.get('cf-ipcountry') ?? null
  const ua = req.headers.get('user-agent')?.slice(0, 200) ?? null

  Promise.all([
    supabase.from('link_clicks').insert({
      link_id: link.id,
      clicked_at: new Date().toISOString(),
      referrer,
      country,
      user_agent_hint: ua,
    }),
    supabase.rpc('increment_click_count', { p_link_id: link.id }),
  ]).catch(() => { /* silently ignore analytics errors */ })

  return new Response(null, {
    status: 301,
    headers: { Location: link.target_url },
  })
})
```

> Pro tip: Return a 301 permanent redirect for SEO use cases and a 302 temporary redirect for campaign tracking. Add a redirect_type column to the links table (permanent|temporary) and use it in the Edge Function: status: link.redirect_type === 'permanent' ? 301 : 302.

**Expected result:** Visiting the Edge Function URL with ?code=abc123 redirects to the target URL. A click row appears in link_clicks. The link's click_count increments. Expired links return 410.

### 3. Build the link management dashboard

Create the main dashboard where users can create links, see all their links in a DataTable with click counts and expiry status, and copy the short URL to the clipboard.

```
Build the URL shortener dashboard at src/pages/Dashboard.tsx.

Requirements:
- Header with app name and a 'Create Link' Button
- 'Create Link' opens a Dialog with a form (react-hook-form + zod):
  - Target URL Input (required, must be a valid URL)
  - Custom alias Input (optional, only alphanumeric/hyphens, 3-30 chars)
  - Title Input (optional, for display in the dashboard)
  - Expires at DatePicker (optional, using shadcn/ui Calendar in a Popover)
  - On submit: if custom alias provided, check uniqueness in links table. Otherwise call generate_short_code() RPC. Insert the link row. Show a success Toast with the short URL.
- Links DataTable with columns:
  - Title / Target URL (two-line cell: title bold, target URL truncated in muted text)
  - Short URL (monospace, with a Copy icon Button that copies to clipboard and shows a check icon for 1s)
  - Clicks (number, right-aligned)
  - Status Badge: 'Active' (green), 'Expired' (red), 'Expiring Soon' (yellow, within 7 days)
  - Expiry (relative date or 'Never')
  - Actions: View Stats (opens a Sheet), Toggle active (Switch), Delete (with AlertDialog confirmation)
- Clicking 'View Stats' opens a Sheet from the right showing the click chart from step 4
```

**Expected result:** Users can create links with optional custom aliases and expiry dates. The DataTable shows all links with live click counts and status badges. Copy-to-clipboard works. Toggle and delete work.

### 4. Add click analytics with a Recharts BarChart

Build the analytics view that appears in the Sheet drawer when a user clicks 'View Stats' on a link. It shows clicks per day for the last 30 days and a breakdown by country.

```
Build a LinkAnalytics component at src/components/LinkAnalytics.tsx.

Props: linkId: string, shortCode: string

Requirements:
- On mount, fetch link_clicks WHERE link_id = linkId AND clicked_at >= 30 days ago from my_link_clicks view
- Aggregate client-side into a clicks_by_day array: [{ date: '2024-01-15', clicks: 12 }, ...] for the last 30 days (fill missing days with 0)
- Render a Recharts BarChart: XAxis shows date labels (show every 7th label to avoid clutter), YAxis shows click count, Bars are blue, Tooltip shows exact date and count
- Below the chart, show total clicks in the period as a large stat
- Country breakdown: group clicks by country code. Show top 5 countries as a list with country flag emoji, country code, click count, and a percentage progress bar. Show 'Unknown' for null country.
- Recent clicks: last 10 clicks as a compact list showing time (relative), country, and referrer domain (extract from referrer URL)
- Show Skeleton placeholders while loading
```

> Pro tip: Use useMemo to compute the clicks_by_day aggregation instead of doing it on every render. The 30-day array generation with date filling is a good candidate for memoization since the raw clicks array rarely changes.

**Expected result:** The analytics Sheet shows a BarChart with daily clicks, a country breakdown, and recent click list — all populated from the Supabase my_link_clicks view.

## Complete code example

File: `supabase/functions/redirect/index.ts`

```typescript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

serve(async (req: Request) => {
  const url = new URL(req.url)
  const pathParts = url.pathname.split('/').filter(Boolean)
  const code = url.searchParams.get('code') ?? pathParts[pathParts.length - 1]

  if (!code || code === 'redirect') {
    return new Response(
      JSON.stringify({ error: 'Missing short code' }),
      { status: 400, headers: { 'Content-Type': 'application/json' } }
    )
  }

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

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

  if (error || !link) {
    return new Response('Not found', { status: 404 })
  }

  if (!link.is_active) {
    return new Response('Link is disabled', { status: 404 })
  }

  if (link.expires_at && new Date(link.expires_at) < new Date()) {
    return new Response('Link has expired', { status: 410 })
  }

  // Fire-and-forget click logging
  const clickData = {
    link_id: link.id,
    clicked_at: new Date().toISOString(),
    referrer: req.headers.get('referer')?.slice(0, 500) ?? null,
    country: req.headers.get('cf-ipcountry') ?? null,
    user_agent_hint: req.headers.get('user-agent')?.slice(0, 200) ?? null,
  }

  Promise.all([
    supabase.from('link_clicks').insert(clickData),
    supabase.rpc('increment_click_count', { p_link_id: link.id }),
  ]).catch(() => {})

  return new Response(null, {
    status: 301,
    headers: {
      Location: link.target_url,
      'Cache-Control': 'no-store',
    },
  })
})
```

## Common mistakes

- **Not adding Cache-Control: no-store to redirect responses** — Browsers cache 301 redirects permanently. If you ever change a link's target URL, visitors with cached redirects will still go to the old URL — possibly forever — until they clear their browser cache. Fix: Add Cache-Control: no-store to all redirect responses. If you want permanent redirects for SEO, use 301 only for links explicitly marked as permanent. Default to 302 (temporary) for all user-created links.
- **Awaiting the click logging before returning the redirect** — Awaiting the analytics INSERT adds 50–200ms of latency to every redirect. This is the opposite of what you want in a redirector — speed is the entire user experience. Fix: Use Promise without await for analytics logging as shown in the Edge Function: Promise.all([insert, rpc]).catch(() => {}). The redirect response fires immediately while the logging happens in the background.
- **Allowing arbitrary short codes without sanitization** — Custom aliases that contain special characters, look like system paths (/admin, /api), or are very short (1–2 chars) can cause routing conflicts and security issues. Fix: Add Zod validation for custom aliases: z.string().regex(/^[a-z0-9-]{3,30}$/).min(3).max(30). Also maintain a blocklist of reserved codes: ['api', 'admin', 'dashboard', 'login', 'logout', 'redirect', 'static']. Check against the blocklist before saving.
- **Not enabling RLS on link_clicks** — Without RLS on link_clicks, any authenticated user can query all clicks for all links — exposing which URLs are popular and who is clicking on other users' links. Fix: Use the my_link_clicks view that already applies the user_id filter via the links join. For direct table access, add an RLS policy that restricts SELECT to rows where link_id is in (SELECT id FROM links WHERE user_id = auth.uid()).

## Best practices

- Validate target URLs before saving with a URL constructor check in the frontend and a Zod URL validator: z.string().url(). Reject malformed URLs and optionally check that the URL returns a non-4xx response via a HEAD request in an Edge Function.
- Store click_count as a denormalized integer on the links row for fast dashboard queries. Increment it atomically using a SQL function rather than a read-modify-write in application code to avoid race conditions.
- Add a rate limit on link creation per user (e.g. 100 links on free tier) to prevent abuse. Check count from links WHERE user_id = auth.uid() before inserting and return an error if the limit is reached.
- Generate short codes that avoid visually ambiguous characters like 0/O and l/I/1. Use a character set of a-z (lowercase only) and 2-9 to eliminate all ambiguity.
- Archive rather than hard-delete expired links. Set is_active = false and keep the row so the short code is never reused for a different URL. Someone might have the old short URL bookmarked or printed.
- Add an optional password protection field to links. If a password is set, the redirect Edge Function serves an HTML form instead of redirecting. On correct password submission, set a cookie and redirect. Store only the bcrypt hash of the password.

## Frequently asked questions

### Can I use my own custom domain for the short links instead of the Supabase Edge Function URL?

Not directly within Lovable. The redirect Edge Function runs at your-project.supabase.co/functions/v1/redirect. To use a custom domain like go.yourapp.com, deploy a lightweight redirect service (Cloudflare Worker or Vercel serverless function) that proxies requests to your Supabase Edge Function. This is a post-Lovable customization step.

### Why use a redirect Edge Function instead of client-side routing?

Client-side routing requires loading the full React app before redirecting — adding 2–5 seconds of wait time. An Edge Function redirect happens server-side in under 100ms, before any JavaScript loads. It also works when JavaScript is disabled. For a URL shortener, the redirect speed is the core user experience.

### What is the difference between a 301 and 302 redirect?

A 301 (permanent) redirect tells browsers and search engines to permanently replace the old URL with the new one. Browsers cache 301 redirects, so users see no delay on repeat visits. A 302 (temporary) redirect is not cached — every visit hits your Edge Function. Use 302 for tracking links where you might change the target URL or want accurate click counts.

### How do I prevent someone from shortening malicious URLs?

Add a URL safety check in the link creation Edge Function or form. Options include: checking the URL against Google Safe Browsing API (free, requires API key), blocking known malicious domains from a public blocklist, or requiring email verification before allowing link creation. At minimum, validate that the URL resolves to a real page with a HEAD request.

### Will the click count be accurate if two people click at the same time?

Yes, because the increment_click_count SQL function uses UPDATE links SET click_count = click_count + 1 — PostgreSQL handles this atomically with row-level locking. This is correct even under high concurrency. Do not use a read-then-write pattern (select count then update) as that creates race conditions.

### Can I make this project work without user authentication for a simpler anonymous shortener?

Yes. Remove the user_id column from links and the auth requirement from RLS. Anyone can create links. To prevent spam, add rate limiting in the link creation Edge Function by checking the client IP against recent inserts: reject if more than 10 links were created from this IP in the last hour. Store IP hashes (not raw IPs) for privacy compliance.

### Is there help available to add features like team workspaces or API access?

RapidDev builds production Lovable apps. For a URL shortener with team workspaces, API access, custom domains, and advanced analytics, reach out to discuss a custom build.

### How do I handle very long target URLs?

PostgreSQL's text type can store URLs up to 1GB, so database storage is not a concern. Add frontend validation to warn users if the URL is over 2,000 characters (IE and old edge cases). For display in the DataTable, truncate the target URL at 80 characters using CSS text-overflow: ellipsis or a JavaScript slice with an ellipsis suffix. Show the full URL in a Tooltip on hover.

---

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