Engineering

·

Building Linq: Rethinking How Teams Manage Links

The problem we set out to solve, the decisions we made, and what we learned along the way.

Abhinav Sharma

Full-Stack Developer

At first, a link feels like a small detail. It may be a short URL for a campaign, a QR code on a poster, or a redirect for a page that has moved. Setting it up takes only a few minutes, so it is easy to generate a link, use it in campaigns, and move on.

Then things change. A website gets a new landing page, a page moves to a new URL, or the destination behind a QR code needs to be updated after thousands of copies have already been printed. You cannot go back and replace every poster, email, or document carrying that link. What looked like an insignificant detail earlier becomes something you need to change without breaking what is already out there.

We had been using Shlink, an open-source, self-hosted URL shortener, to manage our campaign links. It had served us well, but as our campaigns grew, we started running into a few limitations.

One of them was analytics. With more than 10 million visits across our short links over two years, and no pagination in the analytics view, clicking "View all analytics" would attempt to load everything at once, overwhelming the instance and bringing it down. Anyone opening a link during that time could end up on a broken page.

Our Shlink setup also lacked the role-based access control we needed, which meant we couldn't give our marketing team access to create and manage links without also giving them access to administrative operations. As a result, even routine tasks like creating short links had to go through DevOps, since giving broader access meant risking changes that could affect the entire instance.

On top of that, generating QR codes or adding tracking parameters meant switching between different tools. Something as simple as preparing a link for a campaign involved more steps and people than it should have.

During tax season, when campaigns were going out to millions of users every week, that friction became difficult to ignore. The team needed to get emails and ads out quickly, and spending 30 minutes on something that should have taken five was becoming difficult to justify.

We realised that the problem wasn't just about shortening URLs. It was about making link management a smaller part of the work involved in running a campaign.

We considered working around these limitations or extending what we already had. But that would still leave us adapting our workflows around the tool. Building something of our own gave us the freedom to rethink how links, permissions, and campaign workflows should work together.

That led us to build Linq, a self-hosted platform that brings link creation, tracking, QR codes, and routing together in one place. We wanted teams to be able to handle everyday changes themselves, without compromising the reliability of links they had already shared.

How it all comes together

We kept Linq's architecture simple by design. One server handles both the API and redirects, while PostgreSQL stores the data. That's enough to get an instance up and running without requiring a long list of additional services. 


Figure 1. Linq keeps link management and visitor redirects simple, while leaving room to grow.

One UI, wherever Linq runs 

We liked Shlink’s approach of keeping the client separate from the backend. It meant the product could run with just the backend, while a client could connect to it whenever one was needed.

We followed the same idea with Linq. In the browser, a server is simply a saved name, server URL, and API key. One static UI can manage several Linq instances, wherever they run, and every request goes through the same API. That also gives others a common interface to build their own clients or integrations against.

Keeping redirects simple 

When someone opens a short link, Linq follows three steps:

  1. Find the domain and slug.

  2. Check whether a matching rule applies.

  3. Send the visitor to the right destination.

We also keep analytics out of the way of redirects. Visits are recorded asynchronously, so a slow or failed analytics write does not prevent visitors from reaching their destination.

Linq still provides daily and all-time visit counts in the UI, allowing teams to see how their links are performing without making visitors wait for analytics to be recorded.


Figure 2. Visits are recorded separately, so analytics does not hold up a redirect.

Routing a link to the right place 

Routing rules let a link behave differently depending on who opens it. For example, the same link can send a visitor using an iPhone to the App Store and a visitor using an Android device to the Play Store.

These rules are evaluated by priority, with those placed higher up being checked first. Linq goes through them in order until it finds a match, then sends the visitor to the corresponding destination. If no rule matches, the visitor is sent to the link's default destination.

Start with PostgreSQL, add infrastructure when needed 

Linq uses an in-memory cache to handle repeat lookups by default. Teams running multiple servers can use Redis for a shared cache. For those already using Caddy for HTTPS, Linq can also keep its domains in sync with Caddy. 

Neither is required to get started.


Figure 3. Linq starts with the essentials and lets teams add optional infrastructure when they need it.

Decisions behind the build 

We chose Bun and TypeScript for the server, Hono for its lightweight HTTP routing, and Kysely for type-safe SQL queries without the overhead of a full ORM. Together, they gave us a simple stack suited to handling both APIs and redirects. Using shared schemas and types also meant the client and server could work with the same data structures, reducing duplication and making changes easier to manage.

As we built out the APIs, managing permissions became another challenge. Initially, each API key role had its permissions defined manually. While this worked, maintaining those checks became increasingly difficult as we added more operations.

Having used CASL in an earlier project, we realised it would be a good fit for Linq too. It gave us a structured way to define permissions in one place and share the same rules between the client and server, rather than maintaining checks throughout the codebase.

The roles themselves are straightforward: viewers can read, editors can create and update links, and admins can manage domains and keys, archive links, or permanently remove them. Links belong to the team rather than an individual key, so access can be managed without tying links to a particular person.

Beyond deciding who could manage links, we had to think about what should happen when a link was no longer needed. Archiving a link is different from deleting it permanently, and an archived slug remains reserved rather than becoming available for reuse. This prevents an old address from accidentally pointing somewhere entirely different.

Bringing it back to the team

Building Linq reinforced something we had come to realise along the way: creating a link is simple, but keeping it dependable as things change takes a little more thought.

That shaped many of our decisions, from keeping analytics separate from redirects to how we approached permissions, archiving, and deletion. Each came with its own trade-offs, but the goal remained the same: make links easier to manage without compromising their reliability.

What's next

One of the next things we're working on is Link Trees, which will let teams turn a collection of links into a shareable page.

As Linq grows, we'll continue adding capabilities that help teams manage their links, without making the product more complicated to use.

A link is a small promise that an address will take someone somewhere useful. Building Linq was our way of making that promise easier to keep.

At Quicko, several products depend on the same reference data. A due date is a good example. It can change at short notice, and the change itself is small. But when that date appears in fifty places across three codebases, updating it becomes an exercise in coordination.

We needed a single source of truth for this data, that was manageable independently of application releases. We also wanted the people who understand the product and its domain to be able to maintain it. An individual who knows why a date changed should be able to update it with the right access, without an engineer translating the change into code.

Finding a home for that data took us through Google Sheets, then Strapi (among others), and finally to our own headless CMS, Silo.

The tools we tried first

Google Sheets was an accessible start. It gave us a familiar interface and made editing easy. As more products depended on the data, our requirements grew: structure, validation, permissions, reliable APIs, and a sensible way to manage different environments.

Strapi brought those things closer together. It offered content types, an admin interface, and APIs without requiring us to build the whole system ourselves.

The difficulties appeared as we adapted Strapi to our workflows. Defining and changing content types was cumbersome. Extensions required deeper knowledge of its internals. Our data felt tied to Strapi's conventions, and maintaining the setup gradually became the responsibility of a few people who understood those details and had to stay actively engaged to keep things working.

Upgrades compounded the problem. Moving from v3 to v4 and then to v5 each brought challenges around migrating our data and preserving the ways our products accessed it. Queries for related data, particularly through populate, had to be revisited and rechecked after every release so existing integrations kept receiving what they needed. Keeping the CMS current meant carrying knowledge of both the application and the assumptions our products had built around it.

Hosting added another layer. In our cloud-managed setup, updates repeatedly ran into memory limits. Self-hosting gave us more control over the infrastructure, but it left the same people responsible for operating it as well. Over time, the effort required to maintain the system around our data became a significant part of using it.

A tool intended to make data easier to manage had become another system requiring sustained attention. Each of those experiences pointed at a requirement: structure and validation we could not get from a spreadsheet, portability and upgrade paths we could not get from Strapi, and an operation simple enough that it would not need its own owner.

What we were signing up to build

An internal headless CMS had been on our wishlist for some time, and we had been reluctant to take on everything that came with it.

In theory, building a headless CMS sounds simple enough. Define some fields, store records, put forms in front of them, and expose an API. The difficulty appears when you decide how those pieces behave together.

Changing a content type raises questions about the records that already exist. Moving data between environments means deciding what travels with it and how conflicts are handled. Permissions need to stay consistent whether a request comes from a person, an application, or an extension.

Authentication brings choices about accounts, sessions, credentials, and recovery. Supporting plugins means defining what they can access and how closely they depend on the application's internals. Even "easy to self-host" includes installation, configuration, backups, upgrades, and recovering when something goes wrong.

Each feature introduces decisions that someone will eventually have to understand and maintain. As a startup, that commitment competes with building and running the products our customers use. Knowing we could build a CMS was only part of the calculation.

AI changed the other part. We already had years of experience building products that serve millions of users. We knew many of the problems to anticipate and had opinions about the trade-offs we were willing to make. Coding tools made it practical to turn those decisions into a working application alongside our existing responsibilities. Over several weeks, Silo began taking shape.

Starting with familiar foundations

Years of using open-source software gave us plenty to learn from. The tools we valued most were understandable, worked with familiar formats, and left room to adapt them. We wanted those properties in Silo.

Content types are defined using JSON Schema, and entries are stored as JSON documents. The schema describes the data, validates writes, and drives the forms in the admin interface. Developers can work with a standard they may already know, while people maintaining the content get an interface suited to its structure.

Portability got its own attention because of what Strapi's upgrade history taught us. Silo can export and import schemas and data at the project, environment, or collection level. Moving between installations belongs in the core application, including when someone decides to move away from it.

Storage and media providers sit behind adapter interfaces. That separates the application's behaviour from the details of where data lives and gives us a defined place to add or replace providers.

We chose TypeScript to keep the code familiar to our team and to other developers in the ecosystem. Bun gave us a lightweight runtime and the ability to distribute Silo as a single executable, with the admin interface included. The server needs only a handful of external dependencies, which keeps the number of things we must track and update manageable.

For access control, we took inspiration from Shlink's permissions attached to API keys. Silo uses keys with explicit permissions scoped to resources and operations. That supports people, applications, and automation through one model, without requiring a separate account and session system.

These choices gave us a small foundation we could understand and extend.

Designing around everyday use

Once data has a dependable home, the work is in finding it, organising it, changing it safely, and making it available where it is needed.

Managing multiple products and tenants across environments had been a recurring difficulty for us. Projects and environments therefore became part of Silo's core structure. A collection belongs to an environment within a project, and permissions can be scoped accordingly. Staging and production have explicit places in the model, and data transfers operate at those same boundaries.

Search needed to follow that structure without making it an obstacle. Sometimes someone knows exactly which collection they need. Sometimes they only know the value they are looking for. Silo supports searches within a collection, across an environment, or across the data a key can access, with filters to narrow the results.

The media needed more care than an upload button. The library supports folders, tags, and filtering by file type. It also checks whether an asset is still referenced before allowing it to be deleted. That is a small detail until a routine cleanup removes something a product still uses.

Plugins give us a way to accommodate needs outside the core. They can respond to data changes, add routes or admin panels, and introduce providers. Their access is governed by permissions, and their operations go through the same API checks as other clients.

AI access follows the same approach. An MCP server lets agents discover schemas and read or modify data within the permissions of their API key. The REST API provides a common foundation for the admin interface, integrations, plugins, and agents, keeping behaviour consistent across them.

Understanding the code we ship

AI made Silo feasible within the time we had. It also introduced a problem we had to learn to manage: code can be produced faster than a team can absorb it.

Working software needs people who can explain its behaviour, investigate failures, and change it with confidence. We may not be familiar with every line, but we need to understand how the system fits together and where its important decisions live.

That made readability a constraint on how we built with AI. Clear boundaries, consistent naming, and predictable patterns help us review generated code and return to it later. We need to be able to follow a request, locate its validation, and understand how its permissions are enforced.

This takes deliberate guidance. A piece of code can satisfy a request while making the system harder for its maintainers to understand. Keeping generation aligned with human readers is part of the engineering work.

Finding that balance has been a substantial part of building Silo. We are still learning how to preserve the speed these tools offer while retaining ownership of the result. Those lessons will inform how we build and review software across Quicko.

Putting Silo to work

The useful test came when Silo became part of the systems we operate. It had to justify both the infrastructure it consumed and the engineering attention it required.

Our deployment uses CloudFront caching alongside in-memory caches in serverless functions and single-page applications. Much of our reference data is read frequently and changes relatively infrequently, so these caches reduce the traffic that reaches Silo. Products pick up updates according to their cache policies.

Through extensive internal use, this arrangement has met our requirements for cost and maintenance. We have a central place to manage the data, control over how it is served, and an application whose behaviour we can inspect and change.

That experience also defines Silo's scope. You can use it to serve a blog, but our strongest need is the structured information shared across products: data that people with domain knowledge can maintain, that developers can move between environments, and that applications and agents can access through clear interfaces.

We are sharing Silo through Quicko Labs, alongside other open-source tools we build. We have no plans to monetize Silo or the other Labs projects. Their value to us comes from the work they make easier.

Many of the open-source tools we trust grew from a team solving its own problems and continuing to use what it built. We appreciate that relationship: the maintainers experience the consequences of their decisions, and the source gives everyone else a way to examine and adapt them.

The original problem remains a useful measure of this work. When a due date changes, someone who understands it should be able to update it in one place, and every product should have a dependable way to consume it. Making that an ordinary part of running the business is what we wanted from Silo, and what we hope it can offer other teams too.

Found this useful?

Share it with someone who’d enjoy it.

Written by

Abhinav Sharma

Full-Stack Developer