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
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
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.
1Create a URL shortener schema in Supabase:23Tables: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_at5- link_clicks: id, link_id, clicked_at, referrer (text, nullable), country (text, nullable), user_agent_hint (text, nullable)67RLS: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().1011SQL 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 table14- Returns the unique code1516SQL function increment_click_count(p_link_id uuid) RETURNS void:17- UPDATE links SET click_count = click_count + 1 WHERE id = p_link_id1819Add 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.
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.
1// supabase/functions/redirect/index.ts2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'45serve(async (req: Request) => {6 const url = new URL(req.url)7 const code = url.searchParams.get('code') ?? url.pathname.split('/').pop()89 if (!code) {10 return new Response('Missing short code', { status: 400 })11 }1213 const supabase = createClient(14 Deno.env.get('SUPABASE_URL') ?? '',15 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''16 )1718 const { data: link } = await supabase19 .from('links')20 .select('id, target_url, expires_at, is_active')21 .eq('short_code', code)22 .single()2324 if (!link || !link.is_active) {25 return new Response('Link not found', { status: 404 })26 }2728 if (link.expires_at && new Date(link.expires_at) < new Date()) {29 return new Response('Link has expired', { status: 410 })30 }3132 // Log click asynchronously — do not await33 const referrer = req.headers.get('referer') ?? null34 const country = req.headers.get('cf-ipcountry') ?? null35 const ua = req.headers.get('user-agent')?.slice(0, 200) ?? null3637 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 */ })4748 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.
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.
1Build the URL shortener dashboard at src/pages/Dashboard.tsx.23Requirements:4- Header with app name and a 'Create Link' Button5- '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 4Expected 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.
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.
1Build a LinkAnalytics component at src/components/LinkAnalytics.tsx.23Props: linkId: string, shortCode: string45Requirements:6- On mount, fetch link_clicks WHERE link_id = linkId AND clicked_at >= 30 days ago from my_link_clicks view7- 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 count9- Below the chart, show total clicks in the period as a large stat10- 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 loadingPro 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
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'34serve(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]89 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 }1516 const supabase = createClient(17 Deno.env.get('SUPABASE_URL') ?? '',18 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''19 )2021 const { data: link, error } = await supabase22 .from('links')23 .select('id, target_url, expires_at, is_active')24 .eq('short_code', code)25 .single()2627 if (error || !link) {28 return new Response('Not found', { status: 404 })29 }3031 if (!link.is_active) {32 return new Response('Link is disabled', { status: 404 })33 }3435 if (link.expires_at && new Date(link.expires_at) < new Date()) {36 return new Response('Link has expired', { status: 410 })37 }3839 // Fire-and-forget click logging40 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 }4748 Promise.all([49 supabase.from('link_clicks').insert(clickData),50 supabase.rpc('increment_click_count', { p_link_id: link.id }),51 ]).catch(() => {})5253 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.
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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation