Cover image for Next generation of *.quanghuy.dev #2 - Building PayloadCMS

Next generation of *.quanghuy.dev #2 - Building PayloadCMS

In the very last blog post, we already dove into the migration, moving from a legacy CMS to a self hosted, open source CMS solution like Payload CMS. In this post, we will do a deep dive into how I built it, this time with some code snippets.

When working with PayloadCMS, one of the first impressions is how minimal the documentation is. It's not bad or anything, but the documentation really just covers things like, "Hey, we have this and that. Interesting?" Then good luck finding a way to apply it. It's really not easy to understand just by reading the documentation. You have to read it (of course), play with it, try it, feel the pain, and learn from it. That's the overall impression when working with it. Now let's go deeper.

Vendor lock in and the hosting platform

I already mentioned this in the last post, but here we go again. Finding the best and cheapest solutions for your services is not easy, especially at a time when every vendor loves to lock their products into their own cloud platform. For Payload CMS, we need two dependencies: a database and storage. First, let's look at this comparison table:

Service

Engine

Free

Key points

Concerns

Vercel Postgres

PostgreSQL (via Neon)

Yes

~190 compute hrs/mo, 512 MB storage, up to 10 DBs

Tied to Vercel platform; less control than Neon direct

MongoDB Atlas (M0)

MongoDB

Yes

512 MB, shared CPU/RAM, limited ops/sec

Atlas-specific tooling; slower cold starts

Cloudflare D1

SQLite (serverless)

Yes

5 M reads + 100 k writes/day, 5 GB/account

SQLite-only; metered by rows; daily reset

AWS DynamoDB

Proprietary NoSQL

Yes

25 GB storage, 25 RCU/WCU free

AWS-only API; strong vendor lock-in

Turso

libSQL (SQLite)

Yes

500 M reads + 10 M writes/mo, 5 GB storage

Young platform; multi-region latency varies

Neon

PostgreSQL (serverless)

Yes

100 CU-hrs/project/mo, 0.5 GB storage, 5 GB egress

May sleep often; pay for sustained uptime

PlanetScale

MySQL (Vitess)

No (removed)

Paid plans only

No longer free; Vitess layer adds complexity

You couldn't tell me what those solutions are so expensive these days. In the cloud computing era, we bring our services to the cloud, then we have to pay overpriced for most of the product. Now because they are start up, they don't want to burn down money to nothing. Second, they are still using AWS, Azure or GCP under the hood, so why would they offer their best product to us for free? Right. Now, let's choose the best and cheap database option with me, yeah right, Turso. I mean I love sqlite, I love how ridiculous limit it is in terms of everything, but it's fast and cheap, the reason I hate D1 is how its tight to Cloudflare platform, and how bad it's latency is. Vendor lock in is my most break deal issure, so don't count me in. So for the set up part, what do we need? In the beautiful world if ORM, the hardest part just lend those tools a hand, I am using Drizzle and it works beautifully. Let's take a look: Here

What else do you need? Nothing, that's it. The push parameter is kind of annoying. You can turn it off. For other database solutions, what do you mean by those 500MB of content? Pardon me, what would that even store? Let's move on.

For the storage option, you have a couple of options, but S3 compatible storage is the most effective choice. Let's choose Cloudflare R2. It offers much more storage than any other platform, and it's also reliable. Some image URL transformation is also supported out of the box, so you don't have to use an internal transformation package such as the Node.js Sharp package. Honestly, let's point this out: using R2, and Cloudflare R2 specifically, helps me reduce the need for those image editing packages, which are so heavy and easily burn through your CPU usage.

Now look at the code. See the ugly part? Yes, I am writing ugly code because I am just reinventing the basic wheel. S3 compatible storage is quite popular. You can just use the official @payloadcms/storage-s3 plugin to make your R2 functional without any boilerplate code. Remember that you can't use the official R2 plugin or D1 plugin. That's because of vendor lock in.

Yeah, that's it. When you're booting up your minimal template, this is all you need to make it functional. But the thing is, regarding that word "minimal," with the default template, only the media and user collections are available for playing around. Now you need to add more.

Managing your schema based on your needs

Let's scope down the application. We'll build from that minimum and scale up. What do you need for a simple blog website? A user collection, a category collection, a post collection, and a media collection. Let's just start with that. For details, please head here There. This is not a tutorial, so what's the interesting part?

This is where you manage your system's schema, where you define the actual schema for the database. Right, even the database itself. So, a few takeaways:

  • You manage your relationship here.
  • Even if some secret or need to be hidden fields, you should put it here.
  • Need some customization?
    • Custom validation? here
    • Custom UI component? here
    • Custom database related constraint? I don't know, still here.

I feel two ways about this, from two different aspects of my mind: that of a developer and that of a normal person. As a normal person who doesn't like setting up complicated things, this is great. I don't need to care about what database migration actually looks like. I don't want to look at any deeper side. Want to customize a UI part? Just simply put it here. It's so simple; the Payload CMS CLI already takes care of everything you need.

That's why there's this weird concept: importMap.js. Because you hand off everything to Payload CMS, they have to have some mechanism to let the UI rendering part know what and where to map your custom components to the Payload controlled UI. And that's what gives me very mixed feelings about the design philosophy of the framework; it's limited in some ways. It's not that I can't do this or that, but the way I implement things has to strictly follow their weird configuration. Sometimes I just have random questions, like, "Can I customize this?" or "Can I add that?" You know you can't answer those confidently because you have no idea until you check their documentation. But again, their documentation is not very specific.

One more thing before we move on. Because those APIs are very tied to their platform, sometimes you can't just find a workaround and have to accept your fate. There are two real issues I have. First, if you want to query a collection by multiple items in a list field, you can't. The 'all' operator only works on MongoDB or NoSQL databases specifically. There's no way you can customize this. You have to accept this limitation. Second, if you want to customize how GraphQL works, then you can't either. That's private, and they don't expose any hooks for customization. I know they let you add new queries or mutations, but for sure, you can't modify the behavior of the existing ones.

The painful access control system

Now for the interesting part. You've defined the shape of your schema, and now you want to implement some access control rules. PayloadCMS makes this easy, so you can just use their hooks.

Take a click on This.

Again, the good thing is that the access control system is great at first glance. I like how simple it is, right? Four operations, and each has functions for checking permissions. What could be the compromises here? But here we go, there are some odd parts. But first, let's define the access control rules. Take the following as an illustration:

  • Everything in your database is private. You need authentication for access, even for published articles.
  • There are multiple users, and they can create their own records for all types of collections. But in that setup, some collections must be shared.
  • The Category collection is one of the shared collections, since you don't want everybody creating 100+ "Story" type categories.
  • But your category needs an illustration image, so...?
  • Media is private by default, but any media linked to a published article or public category must also be accessible to everyone in the system.
  • Also, images are everywhere, even in the Lexical editor state. You have to check if those images are linked to published article content.
  • Users are allowed to create their own posts but can't modify others' posts.
  • ...

Is your brain hurting yet? Yeah, my brain isn't responding either. And that's where the design philosophy became a limitation for our business. I think we can still achieve the goal; I think it's fine, though. Let's move on. The hook system is kind of weird. Some parameters are nullable in some phases of the operations, and it feels like we're using React hooks, but on the backend side. Sometimes I receive the state, so there's a user ID, but at other times, the user ID is null? I have no idea. Well, the documentation isn't specific either, so I just check some basic cases by rejecting everything and then move on. What should I do better?

The Lexical editor's dev time experience

The Lexical editor is powerful, but it's also very complicated. Let's talk about it from a few aspects.

First, its native state is in JSON format, not HTML. This is not a WYSIWYG editor, no matter what people argue. To actually render that JSON state to the UI, you have a few options. You can translate the JSON state to HTML on demand or have a separate field to store the rendered HTML. The thing is, the styles won't match, and you somehow have to maintain a shared stylesheet. And if you want to do that on the server? You're unlucky, as that requires a DOM manipulation library, and you will run into issues in some specific environments. That's not a good option at all. Or you can just do better by handing off the heavy work to the server using SSR, which is a much better approach. But now you're in the dilemma of having two instances of the editor: one for the actual editor, the editable <div>, and another one that's a read only editor. In this Payload CMS case, you'll want to use Payload's official RichText React component, as this is the read only version of the editor and seems much easier to implement. But, it's not that easy.

Second, the Lexical editor is powerful. You don't want to limit yourself to some basic controls; you want to have some advanced tools in the toolbar, for example, a Wattpad style comment system for highlighting comments, sticky notes, an expandable panel, or even a table and an easy to implement YouTube embed component? You are unlucky again. To create those tools, you have to have very deep knowledge of that damn editor, and right now, you also have to integrate with the Payload drawer system for handling input. The layers are blending, and I am feeling bad. Want an example? Here's one Here.

Third, remember there are two instances of the editor: one for editing and another one for the user facing front end? Yeah, you have to write some converters to translate your custom components into HTML/JSX so the browser can understand.

No hate or anything. If you have time to dig in and want to master this editor, then this is for you. One more note: even the Payload team doesn't seem to have put much effort into supporting many components, and the community around this is pretty young. Also, given the complexity of the editor, you'll want to rely on yourself and the generic examples from the Lexical team. Even the table component isn't ready to use in the official support from the PayloadCMS team, so you know what I mean.

The image service and its implementation

There are so many potential opportunities I want to pursue here, but I'm limited by some factors, mostly just my own bandwidth and having so many things to take care of. For example, there are some cool ideas about having separate buckets for published and private articles, where you move things around, but this would actually add a ton of complexity to the logic, which is limited by the framework. If we try to use a private bucket all the way, then you have a few more options. You can use signed URLs to publish your private media, or you can use the built in solution, accessing the private object and streaming it back to the client. But nah, I don't think I want that approach. But if you use a private bucket, then you can't use Cloudflare's image URL transformation. That's why I accepted the limitations and went with a public bucket, to use Cloudflare's URL transformation and follow some better practices.

Now for the important part: responsive image rendering on the front end. You have to offer some variants of the image so that you will have an optimized front end. A low resolution base64 version, just around 100B if you are using the WebP format, with the entire byte string embedded in the database. An optimized version of the uploaded image. You want a smaller image from users to make it easier to transfer and process on the cloud. Let's use JPG and 1080p as the default resolution for the web. Also, you want to have around eight variants of the images so the browser can select and render them correctly using the srcset and img HTML tags. This can all be done with URL transformation, with no need for local Sharp transformations. But here's the tradeoff: you are processing too much in the request, which slows down the user's web browser. You'll want to hand off the processing work to a queue (Qstash is a good option), and you can fall back to the optimized image while waiting for the entire set to be generated.

You've just done the most annoying part. Let's move on to the harder one.

The immature authentication system & Webhook

Now for the advanced part. Well, you could just listen to the Payload CMS team, but if you know what you are doing, then just go ahead. I do, so here I am. The major issue with the entire system is the limited authentication methods it offers. You have local, basic authentication with a password and username. You have other ways of authorization, and there are even API keys, which are tied to the created users. But let's be honest, this is the biggest issue that prevents any big company from actually using this. Fortunately, the PayloadCMS team offers a good set of hooks and APIs so we can disable their local authentication and replace it with a better one. This way you can have two factor authentication, a deeper layer of RBAC, and a more comprehensive authentication flow with OAuth2. With some complex setup, you can get it running with Auth0 or any other user authentication server. Let's take a look at how it should be done. He and Ho.

Basically, they tell you to overwrite the local authentication behavior and add some hooks to load your custom login component before theirs, and also for logout. Well, I can't say this is true flexibility, but at least there's a way to achieve your goals. This way, you can now customize your authentication solution to the max. But here's another problem to be solved.

Another advanced feature is webhook, you want to publish your change events out, so other services in your own infrastructure can do whatever about it, let's wait for future posts.


Coming up next, curious what I ended up using for my authentication services? Part 3 will expose it to you!


More posts

Next generation of *.quanghuy.dev #1 - PayloadCMS migration

On a journey to rediscover a passion I lost years ago, that of enriching my personal website and the ecosystem around it

Python performance trap

We've been through so many things and realized Python is not perfect, feeling the pain from Python alone is enough, you don't want to take on more