Back to journal
Engineering

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.

PMML Engineering 15 May 2026 8 min read 0 views
Ship a REST API in 15 Minutes with Supabase Edge Functions

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

Database schema design for URL shortener

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

Edge function deployment across global CDN

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

Performance comparison showing edge function speed

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.

#supabase#edge-functions#typescript#api#serverless

Keep reading

You might also like