Self-hosted url shortener using Cloudflare

A serverless solution using Cloudflare Workers

Unsplash

Introduction

In this article, we will learn how to build and deploy your own URL shortener on a serverless platform by Cloudflare.

Cloudflare provides a CDN network, DDoS mitigation, and other cybersecurity products primarily but they have offerings that enable running cloud applications on edge locations (near to the user geographically).

Why serverless?

While self-hosted is appealing it’s quite messy managing your server and the configurations, especially if you are running your business solo.

Before we begin, you can check out a demo-hosted version here: slashurl.co

Dashboard view

Disclaimer: I am the founder of slashurl and this web version is useful if you would not like to self-deploy

Let’s begin!

Prerequisites

  1. Cloudflare Account (with a free tier)
  2. Dynamo DB (if you would like an incremental ID generator, more on it later)
  3. Supabase (for storing the web app metadata in Postgresql, free plan)
  4. Vercel (for deploying the web app, free plan)

Cloudflare Workers — Cloudflare Workers is a serverless platform that allows you to run JavaScript code on Cloudflare’s edge network.

Workers KV — Workers KV is a key-value store that is built into Cloudflare Workers. It is a great way to store data that you want to access from your Workers.

AWS DynamoDB — AWS DynamoDB is a NoSQL database that is built into AWS.

Supabase — Supabase is an open-source alternative to Firebase that provides a suite of tools for building scalable web and mobile applications. It provides a set of backend services, including a PostgreSQL database, authentication, and real-time subscriptions, that can be used to build modern applications.

Architecture

We use Workers KV to store the key->value and the Workers for the redirection.

Optionally, we use DynamoDB for incrementally generated unique IDs, and Supabase for storing the click metrics.

There are a few parts to this tutorial, so breaking it into different sections.

Generating unique shortcodes

There are multiple approaches to generating the shortcode.

Approach 1

Use a uuid generator and truncate to the max length of the URL shortcode you would prefer.

This approach can lead to duplicates, and so you need to check before inserting the data into the key, value store.

Approach 2

Use an incremental counter and convert that into base62 encoded string which is used as a short code.

This counter should be atomically incremented for it to truly scale. Below is the code to do that with DynamoDB.

Table — Short_Urls

Fields: domain and id

// file: idgenerator.ts
...
docClient: DynamoDBDocumentClient;

async incrementId(domain: string) {
    const input = {
        TableName: "Short\_Urls",
        Key: {
            "domain": domain,
        },
        UpdateExpression: "SET id = if\_not\_exists(id, :start) + :incr",
        ExpressionAttributeValues: {
            ":start": 0,
            ":incr": 1,
        },
        ReturnValues: "UPDATED\_NEW",
    }
    const command = new UpdateCommand(input);

    const response = await this.docClient.send(command);
    return response;
}

async generateShortUrl(domain?: string) {
    if (!domain) {
        domain = DEFAULT\_DOMAIN;
    }
    const id = await this.incrementId(domain);
    let code = base62Encode(id.Attributes.id);
    return \`${domain}/${code}\`;
}

Note: An atomic counter can also be developed using Cloudflare Durable Objects as well. Here is a link to the documentation.

REST APIs

There are a few approaches here to publish the API to generate the short urls:

Approach 1

Create the function to generate the short link inside Cloudflare Workers itself. This can work if you plan to generate links via API only.

Approach 2

Use Next.js-based APIs to serve the traffic to perform CRUD operations on the URLs — create, edit, delete

And publish the key, value to Worker KV for faster retrieval.

Approach 2, is useful if you plan to host your own web app and want to manage your links from it.

Generate short URL — API

Payload Schema for POST /urls/create

export interface UrlItemReq {
  id?: number;
  userId: string;
  longUrl: string;
  domain?: string;
  slug?: string;
};

export interface UrlItemRes {
    shortUrl: string;
    longUrl: string;
};

slug is an optional param that can be used to save custom back half for short links instead of the base62 encoded strings

domain is an optional param to generate the shortcode for a particular custom domain

If you plan to store your data in Supabase, below is the schema for urlstable

create table
  public.urls (
    id bigint generated by default as identity,
    created\_at timestamp with time zone null default now(),
    short\_url text not null,
    long\_url text not null,
    expiry timestamp with time zone null,
    clicks bigint null default '0'::bigint,
    user\_id uuid null,
    constraint short\_urls\_pkey primary key (id),
    constraint short\_urls\_short\_url\_key unique (short\_url),
    constraint urls\_user\_id\_fkey foreign key (user\_id) references auth.users (id) on delete cascade
  ) tablespace pg\_default;

On to the code for the API handler —

// file: create.ts
...
async createItem(supabase: SupabaseClient, itemData: UrlItemReq): Promise<UrlItemRes> {
    const { userId, domain = DEFAULT\_DOMAIN, longUrl, slug } = itemData;
    let shortUrl: string;
    if (slug) {
        shortUrl = domain + "/" + slug;
    } else {
        shortUrl = await this.idGenerator.generateShortUrl(domain);
    }

    const shortUrlWithHttps = "https://" + shortUrl;
    // optional if we need the backend to manage the links
    const { data, error } = await supabase
            .from('urls')
            .create(\[
                { userId, shortUrl: shortUrlWithHttps, longUrl }
            \])
            .select();

    // this updates the cloudflare kv with the short->long url mapping
    await this.kvStore.put(shortUrl, longUrl);
    return data;
}

export default async (req, res) => {
    const supabaseClient = createPagesServerClient({ req, res })
    const { data: { user } } = await supabaseClient.auth.getUser();

    try {
        const item = await createItem(supabaseClient, {
            userId: user ? user.id : null,
            id: req.body.id,
            longUrl: req.body.longUrl,
            slug: req.body.slug,
        });
        res.status(200).json(item\["short\_url"\]);
    } catch (error) {
        res.status(400).json({ error: error.message });
    }
}

Note: I have skipped validation for the payload for simplicity.

Redirection Workers

Add the below code for the redirection workers

// file: index.ts
...
urlKv: KVNamespace;
getShortCode(request: Request): string {
    // trim prefix https from url
    return request.url.replace(/^https?:\\/\\//, "");
}

async function handler(request: any, env: Env, ctx: ExecutionContext) {
 try {
    const shortCode = this.getShortCode(request);
    const trackUrl = (await this.urlKv.get(shortCode)) as string;
    if (!trackUrl) {
        return new Response("Short URL not found", { status: 404 });
    }
    return Response.redirect(trackUrl);
 } catch (error) {
    return new Response("Internal Server Error", { status: 503 });
 }
}

// Process the short urls
router.get("/:code", handler)

The mapping in Cloudflare is stored including the domain, to handle custom domains, hence we use the request.url to get the code:

This retrieves the long URL from the kv and redirects the user to it using 302 redirects.

Updating click metrics

We have a few choices here as well.

Approach 1

Update the click counters in the kv metadata directly. Here is more documentation on it.

Approach 2

If however, you are going to use it like a LMS, then saving the click counters alongside the link data in the database will make the view in the frontend a breeze.

To do this, you need to update the redirection worker handler with:

async incrementClicks(shortUrl: string, clicks: number): Promise<void> {
    const { data, error } = await this.supabase
        .rpc('increment\_clicks', { url: shortUrl, counter: clicks });

    if (error) {
        console.error("error updating tracking: ", JSON.stringify(error));
    }
}

async function shortUrlHandler(request: any, env: Env, ctx: ExecutionContext) {
 try {
    ...
    // this doesn't block the click increment while performing redirection
    this.ctx.waitUntil(incrementClicks(request.url, 1))
    return Response.redirect(trackUrl);
 }
 ..
}

Also, we need a RPC increment_clicks in Supabase:

create function increment\_clicks (counter int, url text)
returns void
security definer
as $$
  update urls
  set clicks = clicks + counter
  where short\_url = url
$$
language sql volatile;

We do this instead of directly updating the click metrics since the anonymous access will fail the authorization to update the URL counter in the table. So we implement this via a stored procedure in PostgreSQL.

And voila! You have created a fully functional URL shortener hosted on the serverless platform Cloudflare.

Note: If you wish to extend this with custom domains, you can do this using Cloudflare for SaaS

Building the frontend that connects with the PostgreSQL backend in Supabase

Dashboard built using Next.js/Chakra UI

This is outside the scope of this tutorial but if you are interested in the full code for it you can contact me at slashurlco@gmail.com and I will help to deploy this at the cost of a Coffee :D

;