Making a link shortener with Astro DB

To easily share my Spotify, I like to use link shortener services. I got tired of how they always stopped working, so like any other sane person, I wrote my own.

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

To see it in action, visit my Spotify profile at go.czw.sh/spotify.

I like sharing music. 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 solve my issue 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 even examples are provided for how to work with the DB 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 root, 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. This is my entire 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:

let short;

while (true) {
  short = nanoid();
  const query = await db.select().from(Link).where(eq(Link.short, short));

  if (query.length === 0) break;
}

I also make sure to handle errors (wrapping stuff in try-catch) and return a 5xx response if things go wrong, but I don’t show that here.

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.

---
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.

---
Astro.response.status = 301;
Astro.response.headers.set("Location", originalUrl);
---

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

One thing that is interesting to me is that I wrote no JSX for the /[...slug] route. The only code in [...slug].astro is in the Astro frontmatter, which only gets executed on the server. While Astro was originally launched with static generation in mind, we’ve come a long way, and from the way I’ve been using it, it feels totally possible to do things that are usually delegated to a “full-stack” framework.

Styling and frontend

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 neutral and readable monospaced font.

The rest of the site is pretty standard, but while making it I noticed one tiny, small, insignificant detail. It occurred whenever I clicked on the “Copy to clipboard” 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 “Copy to clipboard” remains in the DOM tree; I’m just hiding it by setting the opacity to zero. This allows the button to maintain its width even when the text changes. In order to center “Copied,” I had to position it 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:

Conclusion

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

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. The source code for this project is available on GitHub and to try it out, visit go.czw.sh.