← All posts

Building Go, a link-in-bio service in Rust

Link-in-bio pages are simple enough that building one yourself feels reasonable. A profile needs a name and a list of places to send people. I wanted something that fitted alongside the other tools on sntx.uk, so I built Go.

Each user gets a page at an address such as go.sntx.uk/@yourname. They can add links through a small dashboard and pick a theme. The same dashboard shows which links people use. The whole service runs as one Rust application with a SQLite database.

Starting with Channel

The first version was called Channel. I wrote it in June 2026 as a multi-user link page service using Axum, a Rust web framework. It had its own email and password registration alongside public profiles. A dashboard handled the links.

Axum keeps the backend fairly easy to follow. Public requests go to a profile handler, while dashboard routes sit behind authentication middleware. The application state holds the database connection and a small set of configuration values. There is no separate API service or frontend build process to keep in sync.

I used SQLite because the data suits it. Users own pages, and pages own links. Page views and click events sit in their own tables. Foreign keys handle cleanup when a record is removed, while write-ahead logging helps the database cope with reads and writes happening together. For a service of this size, running another database server would add more work without improving much.

Turning links into useful pages

A public profile is generated on the server. Go finds the account from the username and loads its page. It then fetches the links in their saved order. An avatar can come from a URL. If none is set, the page uses the first letter of the title instead.

The themes are plain CSS inside the profile response. Minimal and Dark cover the sensible options. Glass uses a coloured gradient, while Neon gives every link a green glow. Retro and Terminal use monospace type with very different backgrounds. Keeping the themes in the Rust handler makes the profile pages self-contained and quick to serve.

Links can also have icons from Simple Icons. The dashboard includes a searchable list, then stores the chosen icon name beside the link. On the public page that name becomes an image URL from the Simple Icons CDN. It saves me from bundling a large icon set into a small application.

Analytics without collecting much

Every public page request records a view. If the URL contains a utm_source value, Go saves that too. The analytics screen can then show the total view count and where visits came from.

Link clicks pass through a short redirect route. The handler finds the original URL and increments the link's counter. It records the event before sending the visitor on. Go can show the most-clicked link without storing an IP address or user agent. That is enough information to learn whether a page is working.

Moving sign-in to Fern

In August I renamed Channel to Go and connected it to Fern, my single sign-on service. Maintaining a second password system for one small app did not make much sense once Fern existed.

The browser now opens Fern's PKCE sign-in flow. Go receives an access token and checks it against Fern's /userinfo endpoint. It uses the verified email to find or create the local account, then issues its own short-lived JSON Web Token for dashboard requests. First-time users choose a username before entering the dashboard.

Changing the authentication model also meant changing the existing database. The old users table required a password hash and a username from the moment an account was created. The new version makes the username optional during sign-in and removes the password column. A small startup migration copies existing accounts into the new table, so the change does not require throwing away local data.

Keeping the project small

The frontend is one HTML file using Alpine.js, with Tailwind loaded in the browser. The backend keeps request handling separate from the database code. A Dockerfile builds the release binary and places it in a slim Debian image.

Go does one narrow job and stays understandable. I can open the route table and see the whole service at once. SQLite keeps deployment simple, while Fern lets it share accounts with the rest of my projects. The result is running at go.sntx.uk.