Skip to main content
RapidDev - Software Development Agency

How to Build a URL Shortener App with Lovable

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.

What you'll build

  • Links table with auto-generated short_code, target URL, and optional expiry date
  • Redirect Edge Function that resolves short codes and logs click analytics asynchronously
  • Click analytics table with referrer, country (from CF-IPCountry header), and timestamp
  • Dashboard DataTable listing all user's links with click counts and copy-to-clipboard buttons
  • Recharts BarChart showing clicks per day for each link
  • Link expiry support with visual countdown and auto-disable after expiry date
  • Custom alias option so users can set their own short code instead of random generation
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner14 min read1–1.5 hoursLovable free tier or higherApril 2026RapidDev Engineering Team
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.

What you're building

The redirect Edge Function is the core of a URL shortener. It receives a request at /functions/v1/redirect?code=abc123, looks up the short_code in the links table, and returns a 301 redirect to the target URL. To keep redirects fast, the click logging is done asynchronously using Promise without await — the redirect fires immediately while the analytics INSERT happens in the background.

Short codes are 6-character alphanumeric strings generated by a Supabase RPC function using PostgreSQL's random() and string manipulation functions. If a collision occurs (the generated code already exists), the function retries up to 3 times. Custom aliases let users choose their own short code, with a uniqueness check before saving.

Link expiry is handled by storing an expires_at timestamp. The redirect Edge Function checks if expires_at is in the past and returns a 410 Gone response if the link has expired. The dashboard shows a visual countdown for links expiring within 7 days and a 'Expired' badge for links past their expiry.

Final result

A complete URL shortener with redirect, analytics, expiry, and a clean management dashboard — built in about an hour.

Tech stack

LovableDashboard UI
SupabaseDatabase and Auth
Supabase Edge FunctionsRedirect handler with async click logging (Deno)
shadcn/uiDataTable, Dialog, Input, Badge components
RechartsClick analytics bar chart

Prerequisites

  • 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

Build steps

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.

prompt.txt
1Create a URL shortener schema in Supabase:
2
3Tables:
4- 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
5- link_clicks: id, link_id, clicked_at, referrer (text, nullable), country (text, nullable), user_agent_hint (text, nullable)
6
7RLS:
8- links: users can SELECT/INSERT/UPDATE/DELETE their own rows (user_id = auth.uid()). Service role can SELECT all (for redirect function).
9- 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().
10
11SQL function generate_short_code() RETURNS text:
12- Generates a random 6-char code from characters [a-z0-9]
13- Uses a loop with up to 3 retries if the code already exists in the links table
14- Returns the unique code
15
16SQL function increment_click_count(p_link_id uuid) RETURNS void:
17- UPDATE links SET click_count = click_count + 1 WHERE id = p_link_id
18
19Add index: CREATE INDEX idx_links_short_code ON links(short_code);
20Add 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
1// supabase/functions/redirect/index.ts
2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
4
5serve(async (req: Request) => {
6 const url = new URL(req.url)
7 const code = url.searchParams.get('code') ?? url.pathname.split('/').pop()
8
9 if (!code) {
10 return new Response('Missing short code', { status: 400 })
11 }
12
13 const supabase = createClient(
14 Deno.env.get('SUPABASE_URL') ?? '',
15 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
16 )
17
18 const { data: link } = await supabase
19 .from('links')
20 .select('id, target_url, expires_at, is_active')
21 .eq('short_code', code)
22 .single()
23
24 if (!link || !link.is_active) {
25 return new Response('Link not found', { status: 404 })
26 }
27
28 if (link.expires_at && new Date(link.expires_at) < new Date()) {
29 return new Response('Link has expired', { status: 410 })
30 }
31
32 // Log click asynchronously — do not await
33 const referrer = req.headers.get('referer') ?? null
34 const country = req.headers.get('cf-ipcountry') ?? null
35 const ua = req.headers.get('user-agent')?.slice(0, 200) ?? null
36
37 Promise.all([
38 supabase.from('link_clicks').insert({
39 link_id: link.id,
40 clicked_at: new Date().toISOString(),
41 referrer,
42 country,
43 user_agent_hint: ua,
44 }),
45 supabase.rpc('increment_click_count', { p_link_id: link.id }),
46 ]).catch(() => { /* silently ignore analytics errors */ })
47
48 return new Response(null, {
49 status: 301,
50 headers: { Location: link.target_url },
51 })
52})

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.

prompt.txt
1Build the URL shortener dashboard at src/pages/Dashboard.tsx.
2
3Requirements:
4- Header with app name and a 'Create Link' Button
5- 'Create Link' opens a Dialog with a form (react-hook-form + zod):
6 - Target URL Input (required, must be a valid URL)
7 - Custom alias Input (optional, only alphanumeric/hyphens, 3-30 chars)
8 - Title Input (optional, for display in the dashboard)
9 - Expires at DatePicker (optional, using shadcn/ui Calendar in a Popover)
10 - 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.
11- Links DataTable with columns:
12 - Title / Target URL (two-line cell: title bold, target URL truncated in muted text)
13 - Short URL (monospace, with a Copy icon Button that copies to clipboard and shows a check icon for 1s)
14 - Clicks (number, right-aligned)
15 - Status Badge: 'Active' (green), 'Expired' (red), 'Expiring Soon' (yellow, within 7 days)
16 - Expiry (relative date or 'Never')
17 - Actions: View Stats (opens a Sheet), Toggle active (Switch), Delete (with AlertDialog confirmation)
18- 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.

prompt.txt
1Build a LinkAnalytics component at src/components/LinkAnalytics.tsx.
2
3Props: linkId: string, shortCode: string
4
5Requirements:
6- On mount, fetch link_clicks WHERE link_id = linkId AND clicked_at >= 30 days ago from my_link_clicks view
7- 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)
8- 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
9- Below the chart, show total clicks in the period as a large stat
10- 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.
11- Recent clicks: last 10 clicks as a compact list showing time (relative), country, and referrer domain (extract from referrer URL)
12- 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

supabase/functions/redirect/index.ts
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
3
4serve(async (req: Request) => {
5 const url = new URL(req.url)
6 const pathParts = url.pathname.split('/').filter(Boolean)
7 const code = url.searchParams.get('code') ?? pathParts[pathParts.length - 1]
8
9 if (!code || code === 'redirect') {
10 return new Response(
11 JSON.stringify({ error: 'Missing short code' }),
12 { status: 400, headers: { 'Content-Type': 'application/json' } }
13 )
14 }
15
16 const supabase = createClient(
17 Deno.env.get('SUPABASE_URL') ?? '',
18 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
19 )
20
21 const { data: link, error } = await supabase
22 .from('links')
23 .select('id, target_url, expires_at, is_active')
24 .eq('short_code', code)
25 .single()
26
27 if (error || !link) {
28 return new Response('Not found', { status: 404 })
29 }
30
31 if (!link.is_active) {
32 return new Response('Link is disabled', { status: 404 })
33 }
34
35 if (link.expires_at && new Date(link.expires_at) < new Date()) {
36 return new Response('Link has expired', { status: 410 })
37 }
38
39 // Fire-and-forget click logging
40 const clickData = {
41 link_id: link.id,
42 clicked_at: new Date().toISOString(),
43 referrer: req.headers.get('referer')?.slice(0, 500) ?? null,
44 country: req.headers.get('cf-ipcountry') ?? null,
45 user_agent_hint: req.headers.get('user-agent')?.slice(0, 200) ?? null,
46 }
47
48 Promise.all([
49 supabase.from('link_clicks').insert(clickData),
50 supabase.rpc('increment_click_count', { p_link_id: link.id }),
51 ]).catch(() => {})
52
53 return new Response(null, {
54 status: 301,
55 headers: {
56 Location: link.target_url,
57 'Cache-Control': 'no-store',
58 },
59 })
60})

Customization ideas

QR code generation

Add a 'QR Code' button to each link row that generates a QR code for the short URL. Use the qrcode.js library (importable via esm.sh in an Edge Function) to generate a PNG and store it in Supabase Storage. Show the QR code in an Image in a Dialog with a download button. QR codes are great for physical printed materials.

Link preview with Open Graph metadata

Add a preview page at /preview/[code] that shows the target URL's Open Graph title, description, and image before redirecting. Fetch OG tags via a Supabase Edge Function using a simple HTML fetch and parse. This is useful for sharing links in messengers that render previews.

Team link management

Add a team_id column to links and a teams table. Team members can see each other's links but only owners can delete. Add a team invitation system using Supabase Auth email invitations. Show a team toggle in the dashboard to filter between 'My Links' and 'Team Links'.

UTM parameter builder

Add a UTM parameters section to the link creation Dialog with inputs for utm_source, utm_medium, and utm_campaign. Automatically append these parameters to the target URL when the link is created. Show a preview of the full destination URL with UTM parameters before saving.

Link bundles for campaign management

Add a bundles table where multiple links are grouped under a campaign name. The bundle view shows aggregate click stats across all links in the bundle, making it easy to measure the total reach of a marketing campaign that uses multiple short links across different channels.

Common pitfalls

Pitfall: Not adding Cache-Control: no-store to redirect responses

How to avoid: 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.

Pitfall: Awaiting the click logging before returning the redirect

How to avoid: 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.

Pitfall: Allowing arbitrary short codes without sanitization

How to avoid: 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.

Pitfall: Not enabling RLS on link_clicks

How to avoid: 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.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a URL shortener redirect handler in Deno (Supabase Edge Functions). I need to generate a unique 6-character short code using PostgreSQL. Write a PL/pgSQL function generate_short_code() that creates a random 6-character alphanumeric code using characters a-z and 0-9, checks if it already exists in the links table, and retries up to 5 times if there is a collision. Return the unique code. Show the complete CREATE OR REPLACE FUNCTION statement.

Lovable Prompt

Add a bulk import feature to the URL shortener dashboard. Add a 'Bulk Import' Button in the header that opens a Dialog with a Textarea. Users paste one URL per line (up to 50). On submit, validate each URL with zod.string().url(), generate a short_code for each valid URL using the generate_short_code() RPC, insert them all in a single supabase.from('links').insert(array) call, and show a summary Toast: 'Created 48 links (2 invalid URLs skipped)'. Show the invalid URLs in a list below the Toast.

Build Prompt

In Supabase, create a scheduled Edge Function that runs daily to find and expire links past their expiry date. The function should: SELECT all links WHERE expires_at < now() AND is_active = true. For each such link, UPDATE is_active = false. Then optionally send the link owner an email notification using Resend, with a list of their links that just expired and a link to reactivate or delete them in the dashboard. Return a summary JSON with the count of links deactivated.

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.