Self-hosted url shortener using Cloudflare
A serverless solution using Cloudflare Workers
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
- Cloudflare Account (with a free tier)
- Dynamo DB (if you would like an incremental ID generator, more on it later)
- Supabase (for storing the web app metadata in Postgresql, free plan)
- 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 urls
table
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