Making a link shortener with Astro DB

To easily share my Spotify profile, I like to use link shortener services. I got tired of how they always stopped working, so the obvious solution here was to write my own.

A repeating pattern of the words "Link Shortener" and the logo, which looks like a brick wall of sort.

I really like talking about and sharing music. To that end, in order to follow my friends and stalk see what they’re listening to, I often send them my Spotify profile. However, ever since they removed custom usernames, I’ve been stuck with the very memorable username of zhwq0rxdn060sgar22e07u193, and searching for my display name is pretty much impossible.

To get around that, I use link shorteners, but I’ve found that they’re very unreliable, and often shut down/stop working. With the recent release of Astro Studio and Astro DB, I thought this was the perfect chance to test it out… and hopefully resolve my woes once and for all.

Setting up the project

First I had to connect with Astro Studio and also add the Astro DB integration. Both of these were relatively simple to set up. Studio integration is explained well, and examples for interacting with the DB are even provided for people that don’t know SQL (me).

One thing that wasn’t too clear was where I should create the config.ts file. It turns out that it should be placed in a db folder outside of your src folder. Otherwise, local type definitions weren’t being generated for me in the .astro folder.

My project only has two routes: a main index.astro for the homepage, and a [...slug].astro file that captures everything else. The former is for creating new links, and the latter is where we do the redirecting.

The database schema is extremely simple: there are three columns, with the short link as the primary key, the original URL, and the date that it was generated. Astro DB makes it very easy to declare the schema:

db/config.ts
import { column, defineDb, defineTable, NOW } from "astro:db";
const Link = defineTable({
columns: {
short: column.text({ primaryKey: true }),
original: column.text(),
created: column.date({ default: NOW }),
},
});
export default defineDb({
tables: {
Link,
},
});

There were many ways to do this, but I wanted to play with forms and Astro’s API routes, so that’s exactly what I did. The user inputs the original URL and upon submitting the form, I make a fetch call to /api/create that handles the generation. To update the UI (e.g. displaying the newly generated link, or showing an error message) I use React to keep track of state.

The backend receives a POST request from the form, with the form data as the body. I use nanoid which generates a random string of characters for me. I’m using an alphanumeric alphabet with a size of 7.

After performing input validation with Zod, I insert the values into the database, and return a response with a 2xx code to signify everything went well. I also return the newly generated short link in the response body for the UI to consume.

Checking for collisions and errors

Any random generator will have a probability of a collision, and this calculator estimates that 266,000 short links need to be generated before we reach a one percent probability of a collision. This is a very tiny number, and if it happens I should probably buy a lottery ticket, but I check anyway by querying the database:

src/pages/api/create.ts
let short;
while (true) {
short = nanoid();
const query = await db.select().from(Link).where(eq(Link.short, short));
if (query.length === 0) break;
}

In the actual production version of this code snippet I make sure to handle errors (wrapping stuff in try-catch) and return a 5xx response if things go wrong.

Redirecting

All potential redirects are handled in [...slug].astro inside the frontmatter. We first check if the slug in the current URL exists in the database. I select all the rows where the short link matches the slug (and there should only be one row), and from that row I extract the original URL.

If no short URL exists in the database, then I simply redirect to the home page.

src/pages/[...slug].astro
---
import { Link, db, eq } from "astro:db";
const { slug } = Astro.params;
if (!slug) {
return Astro.redirect("/");
}
const query = await db.select().from(Link).where(eq(Link.short, slug));
const exist = query.length === 1;
if (!exist) {
return Astro.redirect("/");
}
---

Astro has a built-in way of dynamically modifying the response returned by the server. In particular, I set the status code to 301 and set the Location header to the original URL.

src/pages/[...slug].astro
---
14 collapsed lines
import { Link, db, eq } from "astro:db";
const { slug } = Astro.params;
if (!slug) {
return Astro.redirect("/");
}
const query = await db.select().from(Link).where(eq(Link.short, slug));
const exist = query.length === 1;
if (!exist) {
return Astro.redirect("/");
}
Astro.response.status = 301;
Astro.response.headers.set("Location", query[0].original);
---

Which redirect code?

I spent a bit trying to figure out which redirect status code I should be responding with. Doing a bit of research revealed that most link shorteners use either 301 or 302. I settled on 301 because

  • once a short URL is generated, it will forever be associated with the original URL. “Moved permanently” perfectly captures this idea.

  • the class of temporary redirections doesn’t fit our motives here. Temporary implies that some day, a user will be able to access the resources/page at our short URL. This will never happen; in fact, there is literally no HTML to render or present. More on this below.

  • search engines and crawlers should never show the short URL.

308 also seems to be a valid alternative to 301, but from what I learned it mostly provides more consistency with non-GET operations. We only use GET, so 301 works fine.

Astro as a backend

If you noticed, there exists no JSX, and therefore, no HTML for the /[...slug] route. The only code in [...slug].astro is in the Astro frontmatter, whose content gets executed everytime a request is made to serve that route.

The only reason that occurs is because Astro is server side rendering this specific route on demand. But essentially, I’ve turned this route into another API endpoint like /api/create.

This was an interesting realization for me because it demonstrated Astro’s flexibility, which I really appreciate. I think many people I talk to still believe Astro’s sole purpose is to be a static site generator, and while that is one of its use cases, it’s evolved to be so much more.

From the way I’ve been using it, I would argue Astro can now do everything that used to be delegated to an “actual” fullstack framework. Astro on the frontend, Astro on the backend.

Styling

Choosing colors is hard, so I used Randoma11y to generate an accessible color palette for me. I wanted to keep things simple, so the whole site is just two colors and one font, and I picked Commit Mono because it’s a nice and neutral monospace font.

Copy to clipboard interaction

Most of the frontend isn’t too noteworthy, but I do want to talk about one thing. While writing the copy to clipboard functionality I noticed one tiny, small, insignificant detail. It occurred whenever I clicked on the button:

The button changes width! That’s so annoying! And not really important! But I had to fix it. The solution I came up with was to place two <span> tags inside the <button>, and when it was pressed, it would hide “Copy to clipboard” and show the other. It looks like this:

<button className="relative">
<span className="absolute inset-x-0" hidden>
Copied
</span>
<span style={{ opacity: "100" }}>Copy to clipboard</span>
</button>

The key part is that both elements remain in the DOM tree; I’m just hiding one by setting its opacity to zero. This allows the button to maintain its width even when the visible text changes. This did mean I had to position the “Copied” text absolutely, to account for the fact that the other <span> tag was still inside the button.

I then attached an event listener to the button and used setTimeout() to revert the changes after a short duration. Now the button width remains the same:

Potential future plans

Unless Astro Studio shuts down anytime soon, my link shortener should continue to work. Maybe this time around, my Spotify profile link will last more than a couple of weeks. I hope.

I might add a few more features in the future. Some stuff I have in mind include

  • letting users turn off numbers, capital letters, etc. in their short link

  • customizing the length of the short link (but I think 7 will always be the max)

  • letting users enter a custom short link (instead of being randomly generated)

  • showing an error or “not found” page instead of redirecting to home

  • a fun button that cycles through other colors from Randoma11y

  • setting expiration dates for links. I don’t have too much control over the server (this project is hosted on Vercel), so I might just lazily check (only check if the link is actually accessed).

So if you’re visiting the site in the future and see anything I didn’t describe here, then I probably got around to doing it.

See it in action

Access my Spotify profile at go.czw.sh/spotify.

Also, I’m not sure if you’ve noticed, but every single external URL I linked to in this post has been ran through the link shortener :)

The source code for this project is available on GitHub and to try it out yourself, visit go.czw.sh.