Ship a REST API in 15 Minutes with Supabase Edge Functions
No server provisioning. No Docker. No YAML. Just TypeScript functions deployed to the edge — cold starts under 50ms, globally distributed, free tier included.
Stop configuring servers. Start shipping APIs.
Supabase Edge Functions run on Deno Deploy — globally distributed, sub-50ms cold starts, and you deploy with a single command. Let's build a complete URL shortener API from zero.
What We're Building
A URL shortener with:
- POST /shorten — takes a URL, returns a short code
- GET /:code — redirects to the original URL
- GET /stats/:code — returns click analytics
- Built-in rate limiting and input validation
Setup
# Install Supabase CLI
npm install -g supabase
# Init a new project (or link existing)
supabase init
supabase login
supabase link --project-ref your-project-id
The Database
First, create the table. Run this in Supabase SQL Editor:
CREATE TABLE short_urls (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code text UNIQUE NOT NULL,
url text NOT NULL,
clicks integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX short_urls_code_idx ON short_urls (code);
ALTER TABLE short_urls ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Public read" ON short_urls FOR SELECT USING (true);
Edge Function: Shorten
supabase functions new shorten
Edit supabase/functions/shorten/index.ts:
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
function generateCode(): string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let code = "";
for (let i = 0; i < 6; i++) {
code += chars[Math.floor(Math.random() * chars.length)];
}
return code;
}
function isValidUrl(str: string): boolean {
try {
const url = new URL(str);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
Deno.serve(async (req) => {
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { "Content-Type": "application/json" },
});
}
const { url } = await req.json();
if (!url || !isValidUrl(url)) {
return new Response(JSON.stringify({ error: "Invalid URL" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const code = generateCode();
const { error } = await supabase
.from("short_urls")
.insert({ code, url });
if (error) {
return new Response(JSON.stringify({ error: "Failed to create short URL" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
return new Response(
JSON.stringify({ code, short_url: `https://your-domain.com/${code}` }),
{ status: 201, headers: { "Content-Type": "application/json" } }
);
});
Edge Function: Redirect
supabase functions new redirect
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
Deno.serve(async (req) => {
const url = new URL(req.url);
const code = url.pathname.split("/").pop();
if (!code) {
return new Response("Not found", { status: 404 });
}
const { data, error } = await supabase
.from("short_urls")
.select("url, clicks")
.eq("code", code)
.single();
if (error || !data) {
return new Response("Not found", { status: 404 });
}
// Increment clicks (fire-and-forget)
supabase
.from("short_urls")
.update({ clicks: data.clicks + 1 })
.eq("code", code)
.then();
return new Response(null, {
status: 302,
headers: { Location: data.url },
});
});
Edge Function: Stats
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
Deno.serve(async (req) => {
const url = new URL(req.url);
const code = url.pathname.split("/").pop();
const { data, error } = await supabase
.from("short_urls")
.select("code, url, clicks, created_at")
.eq("code", code)
.single();
if (error || !data) {
return new Response(JSON.stringify({ error: "Not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify(data), {
headers: { "Content-Type": "application/json" },
});
});
Deploy
supabase functions deploy shorten
supabase functions deploy redirect
supabase functions deploy stats
That's it. Three commands and your API is live at the edge.
Test It
# Shorten a URL
curl -X POST https://your-project.supabase.co/functions/v1/shorten \
-H "Authorization: Bearer YOUR_ANON_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://pmml.info"}'
# Get stats
curl https://your-project.supabase.co/functions/v1/stats/abc123 \
-H "Authorization: Bearer YOUR_ANON_KEY"
Why Edge Functions Win
Cold Starts Under 50ms
Traditional serverless (AWS Lambda, Cloud Functions) cold-starts range from 200ms to 3+ seconds. Edge functions use V8 isolates — the same tech as Cloudflare Workers — so cold starts are nearly instant.
Global Distribution
Your function runs on 30+ edge nodes worldwide. A user in Accra hits a nearby node, not a server in us-east-1.
Zero Infrastructure
No Dockerfile. No Kubernetes. No Terraform. You write TypeScript, run one command, and it's deployed.
Cost
Supabase free tier gives you 500K edge function invocations/month. That's enough for most startups.
Exercises
Exercise 1: Rate Limiting
Add in-memory rate limiting:
const rateLimits = new Map<string, number[]>();
const WINDOW_MS = 60_000;
const MAX_REQUESTS = 10;
function isRateLimited(ip: string): boolean {
const now = Date.now();
const timestamps = rateLimits.get(ip) ?? [];
const recent = timestamps.filter((t) => now - t < WINDOW_MS);
recent.push(now);
rateLimits.set(ip, recent);
return recent.length > MAX_REQUESTS;
}
Exercise 2: Custom Short Codes
Allow users to specify their own codes:
const { url, custom_code } = await req.json();
const code = custom_code || generateCode();
// Check if custom code is taken
if (custom_code) {
const { data } = await supabase
.from("short_urls")
.select("id")
.eq("code", custom_code)
.single();
if (data) {
return new Response(
JSON.stringify({ error: "Code already taken" }),
{ status: 409, headers: { "Content-Type": "application/json" } }
);
}
}
Exercise 3: QR Code Generation
Return a QR code for any shortened URL:
import QRCode from "https://esm.sh/qrcode@1.5.3";
// Add to your stats endpoint
const qr = await QRCode.toDataURL(data.url);
return new Response(JSON.stringify({ ...data, qr }), {
headers: { "Content-Type": "application/json" },
});
From Tutorial to Production
The patterns here — input validation, fire-and-forget updates, stateless functions, edge deployment — are the same ones behind production URL shorteners like Bitly and Dub.co. The difference is scale, not architecture.
What API will you build first? Tell us at pmml.info@gmail.com.
PMML Engineering builds serverless APIs, web platforms, and AI products for teams that ship. Based in Accra, Ghana.