Why?
I have built this kind of site a million times trying to think of the best way for someone to own their own data. One of the last things I did before I left Glitch was to add onlyfans to the glitch in bio default application as a supported URL field because those were personal websites that someone owned. This feels the same?
The Stack
Nothing fancy:
Bun: JavaScript runtime. Honestly was the easiest when I was going to use sqlite, but still faster than node.
Hono: Lightweight web framework.
PostgreSQL: Only for caching and sessions. Most data lives on your PDS.
Template literals: Just backticks š (did i come to regret this? perhaps).
Lexicon
A lexicon is a schema that describes a type of data in ATProto. Bluesky has lexicons for posts (app.bsky.feed.post), likes, follows, etc. LinkRing adds three:
lol.linkring.linklist.list # Link lists
lol.linkring.webring.ring # Webrings
lol.linkring.actor.profile # Profile overridesWhy lol.linkring? The convention is reverse-domain-name, like Java packages, I guess? I own linkring.lol, so I get that namespace. This just seemed to be how other people named theirs so I did the same
The Link List Lexicon
Here's what a link list record looks like:
interface LinkListRecord {
$type: 'lol.linkring.linklist.list';
name: string;
description?: string;
isPublic: boolean;
links: Link[];
createdAt: string;
updatedAt?: string;
}
interface Link {
url: string;
title: string;
description?: string;
icon?: string;
}The $type field tells ATProto what schema this record follows. The rest lives on the user's PDS, not my database.
The Webring Lexicon
Webrings are similar but include members (sites) and an optional button image:
interface WebringRecord {
$type: 'lol.linkring.webring.ring';
name: string;
description?: string;
isPublic: boolean;
button?: BlobRef; // ATProto blob reference
members: WebringMember[];
createdAt: string;
updatedAt?: string;
}
interface WebringMember {
url: string;
name: string;
description?: string;
}The button field is a blob reference, not a URL. The actual image is uploaded to the user's PDS as a blob, and I store a reference to it. When displaying, LinkRing proxies the blob through its own endpoint with caching. The user could display it from the PDS, but I'm just making it a little faster.
The buttons themselves are shown at 88x31 around the site and should invoke painful nostalgia. I still need to find the right pixel font to make mine. Maybe a builder of some kind?
OAuth Scopes
Here's the production OAuth scope:
export const oauthScope = 'atproto repo:lol.linkring.linklist.list repo:lol.linkring.webring.ring repo:lol.linkring.actor.profile';I don't need access to everything. LinkRing can't read your posts, your DMs, your likes. It can only read and write to the specific collections it needs. I could obviously grab those things from the PDS (as you can see below), but not write to them!
During development, I use atproto transition:generic for broader access, which broke me a few times going between live and my development š« .
The Loopback Trick
For local development, ATProto supports "loopback clients". You don't need a registered app, you just encode your redirect URI and scope in the client ID:
function buildClientId(): string {
if (env.isDev) {
const redirectUri = `${env.publicUrl}/oauth/callback`;
return `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(oauthScope)}`;
}
return `${env.publicUrl}/oauth-client-metadata.json`;
}This means you can develop locally without registering anything. When you deploy, you switch to a proper client metadata endpoint.
Profiles
LinkRing fetches your Bluesky profile live from app.bsky.actor.profile. Updates to your Bluesky bio show up immediately.
If you want your LinkRing profile to differ from Bluesky, there's a separate lexicon for that:
interface ProfileRecord {
$type: 'lol.linkring.actor.profile';
displayName?: string;
description?: string;
website?: string;
showBlueskyLink?: boolean;
}
If this record exists, I merge it with your Bluesky profile. If not, I just use Bluesky.
Reading Without OAuth
You don't need OAuth to read public records. Anyone can fetch link lists directly from a PDS:
async function fetchLinkList(handle, rkey) {
// Resolve handle to DID
const didRes = await fetch(
`https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`
);
const { did } = await didRes.json();
// Get PDS endpoint
const plcRes = await fetch(`https://plc.directory/${did}`);
const didDoc = await plcRes.json();
const pds = didDoc.service.find(s => s.id === '#atproto_pds').serviceEndpoint;
// Fetch the record
const recordRes = await fetch(
`${pds}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=lol.linkring.linklist.list&rkey=${rkey}`
);
const { value } = await recordRes.json();
return value;
}LinkRing provides a nice UI, but you could write your own tool to display link lists without ever talking to linkring.lol or needing a server.
I built a /diy page showing exactly how that would work with a really simple bit of javascript.
Jetstream for Discovery
For the Explore page, I needed to know when people create or update public link lists. Enter Jetstream, a simplified firehose for ATProto events.
Instead of processing the full Bluesky firehose (millions of posts), Jetstream lets you subscribe to specific collections. I subscribe to lol.linkring.linklist.list and lol.linkring.webring.ring, cache public records, and show them on the Explore page.
const COLLECTIONS = [
'lol.linkring.linklist.list',
'lol.linkring.webring.ring',
];
const url = new URL('wss://jetstream2.us-east.bsky.network/subscribe');
url.searchParams.set('wantedCollections', COLLECTIONS.join(','));This is how LinkRing discovers content without requiring users to "publish" or "submit" anything. Make your list public, and it shows up. Delete it, and it disappears. The website is just a view on data you already own.
What's Actually In My Database?
Given all this "your data lives on your PDS" talk, you might wonder what LinkRing actually stores:
OAuth sessions: Login tokens. Log out and they're gone.
Cached public records: For the Explore page. Copies, not sources.
Link preview metadata: OG titles, descriptions, images. Caching for performance.
Jetstream cursor: A bookmark for where I am in the event stream. One number.
That's it. If LinkRing vanished, you'd lose nothing except convenience of a page with them listed in order.
The Embed Component
The last piece: I wanted people to embed their link lists on external sites if they don't want to write a line of javascript. This meant making something available, and why not try building a web component to share broadly for the first time:
<script src="https://linkring.lol/embed.js"></script>
<linkring-list src="https://linkring.lol/@yourhandle/lists/abc123"></linkring-list>The component fetches from a JSON API endpoint, renders with Shadow DOM for style isolation, and exposes ::part() selectors for customization. It's about 5KB.
The nice thing about building on ATProto: even the embed could fetch directly from the user's PDS if needed. I use my API for caching and OG metadata enrichment, but the underlying data is always accessible.
Shipping It
I deployed to Fly.io (PostgreSQL, easy scaling) and bought linkring.lol. Total time from "I should build this" to "it's live": ~about a day. I mostly wanted to be able to point to something and say "this is why I think atproto is cool".
The hard problems (identity, data storage, OAuth) are handled by the protocol. I just had to make some lexicon json, wire up some forms without a framework, and make it look okay (thank you open-props).
If you want to build something on ATProto, my advice: just go for it!
What's Next
I'm going to make it easier to style the profile and link pages, maybe by allowing css?
References
Beyond the Statusphere: OAuth TLDR (this really helped the most)