Skyscape Towers by Lowfipromt

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

🔔 Disclaimer: I am not an engineer!

I am a copypasta web dev at best, so forgive me if when I misuse phrases or misunderstand concepts here. I mostly don't know what I'm doing and am just trying things, often for the first time. And I want to encourage you to do this too—that's kinda the point of this post—try, and see what happens!

What I made.

When readers visit the articles page on my site, they'll see content fetched in real-time from records stored in my data repo.

View the Code Repo on Tangled →

A screenshot of website in development, with the Leaflet articles embedded, read directly from the PDS
A screenshot of my Leaflet publication called "Measuring" with a selection of posts.
A screenshot of my Leaflet publication called "Marginalia" with a selection of posts.

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.

And, 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 🤞.

The Setup

AT Protocol uses something called 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.

View the Configs on on Tangled →

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;

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 the Types

Before fetching anything, I needed to define some types that match AT Protocol's data structures. This is where things got really real. Painful, but like, that fun pain!

View the Types on Tangled →

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 all return data, loading state, and potential errors in the same shape.

The types were crucial for understanding what I was 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 these types defined, everything else kinda clicked into place.


💚 PDSls by @Juli.ee made understanding pub.leaflet.publication pub.leaflet.document and seeing how my real data looked like super easy.

Fetching the Data

I also created a generic hook that can fetch records from any collection.

View the Hooks on Tangled →

  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.

This is specific to me using Leaflet as my CMS and you may have to do this differently for other sources.

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 };
}

Using it in 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.

View the Writing Page on Tangled →

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.

@renderg.host/renderghost
My personal website
https://tangled.org/@renderg.host/renderghost

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!

Acknowledgements

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.

  • Taproot by Sri and PDSls by Juliet were invaluable for exploring and understanding how my PDS is structured. Being able to visually browse my own data made everything click.

  • Leaflet by Brendan, Celine, and Jared for their openness and for building my CMS for free 🤣!

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