Skyscape Towers by Lofip
Updated April 2026: I rebuilt the site from the bottom up, and so I have replaced the links and images in this post.
I'm rebuilding my own personal site at renderg.host as a learning project—a simple React site where I can control everything, without depending on increasingly expensive locked-in platforms.
I was a happy Webflow+Wordpress user for a while. As someone still learning web dev, the builder UX is genuinely great, but their pricing? Not so much.
This week, as I considered my CMS options (like Ghost and Strapi), I had an epiphany: I'm already publishing to multiple publications on Leaflet. Why waste time replicating it? Why not connect all my stuff together? AT Protocol provides everything I need—for free—and I retain full ownership and control of my stuff.
No vendor lock-in, no proprietary formats, no surprise pricing changes, just my stuff living in my data repo (hosted for now on Bluesky Eurosky's PDS). So rather than worry about if I could or should, I remembered I can just do things and gave it a shot! It's going really well so far.
I made this
When readers visit the articles page on my site, they'll see content fetched in real-time from records stored in my PDS.
I can write articles on Leaflet, and they automatically appear on my site. Leaflet is essentially my CMS, even though it wasn't designed to do this and has no features specifically for headlessness.
It's surprisingly straightforward—no complicated backend, no database, no API keys, just simple HTTP requests to public endpoints. Because everything lives in my data repo, I could switch to any other AT Proto compatible hosting tomorrow and nothing (or so they tell me) would break 🤞.
Update April 2026: I recently switched hosting to Eurosky and nothing broke when I make this single change to my site's code.
And this is how easy it was to start grabbing content from my @eurosky.social -hosted data since migration 🤩
Initial Setup
AT Protocol uses XRPC to let you query data from any PDS. You don't need authentication for public data—just construct the right URL and fetch away.
First, I declare my DID (personal identifier) and PDS location, and the collections I want to reference.
export const ATPROTO_CONFIG = {
DID: 'did:plc:s2rczyxit2v5vzedxqs326ri',
PDS_URL: 'https://rooter.us-west.host.bsky.network',
CDN_URL: 'https://cdn.bsky.app',
} as const;
export const ATPROTO_COLLECTIONS = {
PUBLICATION: 'pub.leaflet.publication',
DOCUMENT: 'pub.leaflet.document',
} as const;
Update April 2026: Leaflet migrated to the standard.site lexicon (with Offprint, PCKT and others) so my site now references thesite.standard.documentandsite.standard.publicationcollections.
Then, to fetch records, I had to build XRPC URLs for fetching records from collections, and blobs of binary data (like images):
export function buildRecordsUrl(collection: string, repo: string = ATPROTO_CONFIG.DID): string {
return `${ATPROTO_CONFIG.PDS_URL}/xrpc/com.atproto.repo.listRecords?repo=${repo}&collection=${collection}`;
}
export function buildBlobUrl(blobRef: string, did: string = ATPROTO_CONFIG.DID): string {
return `${ATPROTO_CONFIG.CDN_URL}/img/avatar/plain/${did}/${blobRef}@jpeg`;
}💚 Taproot by SRI made finding this information a breeze.
Defining Types
Before fetching anything, I needed to define some types that match AT Protocol's data structures.
This is where things got really real.
export interface ATProtocolBlob {
$type: 'blob';
ref: {
$link: string;
};
mimeType: string;
size: number;
}
export interface ATProtocolRecord<T = unknown> {
uri: string;
cid: string;
value: T;
}
export interface PublicationValue {
name: string;
base_path: string;
icon?: ATProtocolBlob;
description?: string;
$type: string;
}
export interface DocumentValue {
title: string;
description?: string;
publishedAt: string;
publication: string;
$type: string;
}
export type ATProtocolPublication = ATProtocolRecord<PublicationValue>;
export type ATProtocolDocument = ATProtocolRecord<DocumentValue>;
This keeps my hooks consistent. They return data, loading state, and potential errors in the same shape. Types are crucial to understanding what I am actually fetching. Looking at PublicationValue, I can see that publications have a base_path and an optional icon. Documents reference their publication by URI and include a publishedAt timestamp.
Once I had types defined, everything else clicked into place.
💚 PDSls by @Juli.ee makes it soooo easy to explore my Atmosphere data, understand the content (which kept in collections like my blog posts and book reviews) and how their schema are structured.
Fetching Data
I also created a generic hook that can fetch my records from any collection in the PDS.
const [data, setData] = useState<ATProtocolRecord<T>[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchRecords = async () => {
try {
const url = buildRecordsUrl(collection);
const response = await fetch(url);
const data = await response.json();
setData(data.records);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchRecords();
}, [collection]);
return { data, loading, error };
}
This is just standard React stuff—fetch on mount, handle loading states, catch errors. Nothing fancy 🤷♂️ and still broken in places.
Here's where it got interesting! Leaflet articles reference their Leaflet publication by URI, so I needed to fetch multiple collections, squish them together, and do a little transformation.
export function fetchPublications(): FetchResult<Document[]> {
const { data: publicationsData } = fetchRecords(ATPROTO_COLLECTIONS.PUBLICATION);
const { data: documentsData } = fetchRecords(ATPROTO_COLLECTIONS.DOCUMENT);
useEffect(() => {
// Create a lookup map of publications
const publicationMap = new Map();
publicationsData.forEach((record) => {
publicationMap.set(record.uri, {
name: record.value.name,
basePath: record.value.base_path,
icon: record.value.icon ? buildBlobUrl(record.value.icon.ref.$link) : undefined,
});
});
// Match documents with their publications
const documents = documentsData
.map((record) => {
const publication = publicationMap.get(record.value.publication);
return {
title: record.value.title,
description: record.value.description,
publishedAt: record.value.publishedAt,
articleUrl: `https://${publication.basePath}/${slug}`,
publication,
};
})
.sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
setData(documents);
}, [publicationsData, documentsData]);
return { data, loading, error };
}This is specific to me using Leaflet as my CMS and you may have to do this differently for other sources.
Connecting Components
Now, using it in pages is dead simple. Here's a simplified example of how I use it on the writing page on my site, wiring it into a dedicated card component.
export default function WritingPage() {
const { data: documents, loading, error } = fetchPublications();
if (loading) return <div>Loading posts...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
{documents.map((doc) => (
<CardArticle
key={doc.uri}
article={{
title: doc.title,
subtitle: doc.description,
articleUrl: doc.articleUrl,
publication: doc.publication.name,
published: doc.publishedAt,
}}
/>
))}
</div>
);
}That's it... kinda!
No API keys, no auth flow, no schemas to maintain. Just fetch and render! Maybe there's a better way, but for now, this gives me what I want. Do look at the repo as I skimmed a lot for the sake of brevity in this post which is already quite long.
Benefits for me
No vendor lock-in
My content lives on my PDS. If Leaflet disappeared tomorrow (it won't 💚 but hypothetically), I could switch to any other AT Protocol publishing tool or build my own.
No CMS bills
I'm not paying $20-50 a month for a headless CMS. My PDS hosts my content, and reading from it is free. Also, I'm not using any of Leaflet's resources.
Content portability
Because it's all AT Protocol, my writing could theoretically show up in any compatible app. It's genuinely portable in a way that CMS-locked content never is.
Learning opportunity
Building this taught me way more about web protocols and data fetching than I would have learned using an off-the-shelf CMS.
Control
I decide how to use my stuff. No fighting with a another company's opinions about how things should work for me.
Limitations
It's read-only so I have to write on Leaflet, not in my own admin panel.
There's no caching layer (yet) so every page load hits the PDS (For my low-traffic personal site, this is absolutely fine).
If my PDS goes down, so does my writing page. I guess the same is true of any 3rd party CMS (especially if it's hosted on AWS amirite 👹).
I needed to do a LOT of work to understand data structures: It's definitely not as plug-and-play as a traditional CMS. For me, these trade-offs are totally worth it and it was fun to get through it all.
Could You Do This?
Fuck yeah you could! The AT Protocol documentation is excellent, and the community is incredibly helpful! Use AI if you need to. You don't need to be a professional or expert—I'm not! Push things forwards and try stuff out!
Leave a comment or @ me on Bluesky if you learn something new.
Thanks to
This would not exist without the incredible work of others
The AT Protocol team for building something genuinely open and well-documented. The official docs are some of the best technical documentation I've encountered.
Standard Site for making the Atmosphere more unified.
Dan Abramov for the inspiration that we can just do things, and for his excellent guide to AT Protocol that helped me understand the fundamentals and realise I can just do stuff!
And most importantly, to my girly for being so patient while I wrote this thing at speed while she waited for us to go out for a walk this fine cold Berlin Sunday morning.