Building a site with Astro & Payload

The good, the bad, and the ugly

7 min read
#meta
by
protocol7 avatar
protocol7
A screenshot of WebStorm IDE displaying code from my personal site, proto.cool

The Beginning

The setting: the year is 2023. A guy who goes by protocol7 online is attempting to build a personal website. At the time, he was mostly experienced with React and had never heard of something like Astro. There were static site generation options with frameworks like Next.js, of course, but this one was new to him. And therefore exciting.

And that's when it all went downhill.

As you can probably surmise, the year this post was published is now 2025. So, did it take two years for me to build this? Thankfully, no, not exactly. I haven't been working on the site for that long. Frankly, each year had a new iteration, and there was a lot of downtime inbetween work. 2024 was a particularly rough year, being involved in a car accident that left a brand new Toyota Tacoma totaled (pour one out for the AvoTaco), having my own health issues, and having health issues with my pets too on top of it all.

Also, I'm a control freak weirdo perfectionist, and none of the prior iterations have been "good enough."

Which is not to say I don't have issues with the current iteration, but now I'm just ranting, so I digress.

Choosing Astro

I'll start with the frontend. Why Astro? I'm not gonna try to repeat anything that you can just read off their website; their marketing is certainly better than any drivel I can come up with. But the gist of it, for me, is intention. It's very static-first by default, and I believe the web should be static where possible. Interactivity has to be opted-in very intentionally, and even then, by default, it's mostly vanilla JavaScript. There are Component Islands, of course, but there are caveats to that, the most unfortunate being that you can't import Astro components for use in them. I understand why, but even for just template-style Astro components, it'd be a big boon to be able to reuse the markup and styling.

But beyond that, it's not super opinionated, which is perfect. Because, again, I'm a control freak weirdo perfectionist.

Choosing Payload

The backend, which is Payload CMS, is the more interesting choice, arguably. CMSes are typically more dynamic, and if there are static components, it's tightly coupled to the CMS itself. Which, in Payload's case, you can certainly build a Next.js frontend right next to your Payload stuff. It's built that way so that people can slot it into existing Next.js projects, which is great. But obviously, I didn't pick Next.js. Fortunately, the blank template they provide is more or less just the backend components.

And that leads into the next part of why I actually chose Payload; it's extremely customizable. There was certainly a significant learning curve at the start, but once I got the hang of it, I really grew to like the platform. How much of that is Stockholm syndrome, I will leave the reader to decide.

But, having complete control of your schema, as well as hooks for basically every lifecycle of schema collection item events, is frankly very powerful. It's probably overkill, but overengineering may as well be my middle name. Astro supports Markdown and MDX content, but, well, I'm just too cool for plain Markdown files. 😎 Besides, Payload's rich text editor is very extensible; it's built on lexical, such that I was able to use lexical-specific approaches for processing the rich text content.

The Good, The Bad, and The Ugly

Overall, everything went fairly well. Payload's API already comes with a lot of niceties that you don't have to worry about building yourself. Namely, pagination, querying, sorting, et cetera. Not that any of that was particularly hard with frontmatter in Markdown, but having it for free on every data collection in your backend application is amazing.

Problems started to arise mostly in context of pagination. Astro also has its own built in controls for paginating documents. I very well could have just used that and retrieved every document at once from Payload, but keeping the, well, load on my API down by querying a page at a time just seems preferable. And, in the end, the only major change I had to make to Astro was adding the page information to the static paths. That is to say, the actual markup wouldn't change much if it weren't paginated.

For example, here's all the code required for getStaticPaths():

src/pages/blog/[...page].astro
import { getAllPosts } from "@lib/payload/utils";
import type { PaginatedResponse } from "@lib/types";
import type { Post } from "@lib/payload/payload-types";

export async function getStaticPaths() {
    const postPages = await getAllPosts();

    return postPages.map((page) => ({
        params: { page: page.page },
        props: { page },
    }));
}

const { page } = Astro.props as { page: PaginatedResponse<Post> };

Aside from being a bit of a tongue twister (a joke I've already made on Bluesky), it's not that much on it's surface level. I am a page, numbered page.page, and my data, passed as the entire page object, lives at page.docs as per Payload convention. Awesome! Of course, some of the "magic" is done under the hood of helper functions like getAllPosts(), but most if it is frankly boilerplate to ensure consistent paging, sorting, etc.

Where things really did not go so well is content rendering. Astro nor Payload offer official plugins to facilitate this connection, but that's not a problem because it's not necessary. There was about a solid week of trial and error of figuring out how to parse the lexical rich text JSON, and then map it to a component in Astro. The parsing, of course, was the easy part. There are fields you'd expect like type that you can operate on. But rendering it was a little different.

For one, the structure of the rich text content is basically recursive. It's effectively not in practice, as in, the top level document is root, then it can have a paragraph child, which itself will have children of types text, with various properties to inform formatting and whatnot. I originally wanted the content renderer to be recursive, but due to the aforementioned fact that the document basically has a max depth and certain elements are expected to show up at certain depths, it was easy enough to build on that assumption. And it worked.

One problem I've still yet to crack is when Astro hits the Payload API and builds a page, if you update the page in the API, Astro won't rebuild it. I suspect I can configure this with Astro itself somehow, but if not, restarting the dev process is a single click in WebStorm, so not exactly a veritable pain in the rear. Plus, I plan to eventually have Payload's hooks run a rebuild hook on something like GitHub Pages automatically, and then it won't matter; as soon as content is published, the production site will do what it has to do for a new static build and deployment.

In conclusion

The backend is currently open source at GitHub; Payload's project defaults are secure, and because my content is hosted on a database separate from the code repository, there is no risk of anything personal leaking. It's good to go and you can even pull my container from GHCR and run it with the environment variables set.

I will open source the frontend soon once I've made sure certain licenses are adhered to (I use FontAwesome Pro and certainly do not intend to distribute it via an open source project, among other things).

But at the end of the day, I wanted to re-establish myself on the internet in my own webzone. I could yak shave with this website forever, but at some point you have to say "good enough" and start shipping content. The website, itself, can evolve over time, too. Some things certainly aren't perfect or complete, but I'm a big believer in "something is better than nothing."

So, hello world, and please feel free to contact me at any of the social links (Bluesky preferred) if you have questions or comments!