Back to posts

My first time using Payload CMS: making a hardcoded logo uploadable through the admin

I was curious to try out the Payload CMS v3 website template the other day. Its "self-hosted", "integrated into your NextJS project" approach was completely new to me.

Since the CMS was still fairly new, I couldn't easily find a tutorial that walked through exactly what I wanted, the way I usually do on older stacks. I still decided to push through and figure it out.

I started by asking AI, which was mostly wrong, but it gave me a rough direction. After reading the documentation, and doing something I call "reverse engineering", plus some trial and error, I got a small feature working end to end.

Over the years of working on different kinds of projects - legacy, freelance, older and newer stacks - I practiced a skill of being able to dig through an unfamiliar project or library, analyze it, and find features or solutions to a problem close to mine; then follow that process, as well as code patterns, and apply it to what I need to do. I like it for two reasons - it keeps my code in line with the coding standard of the project, and more importantly, it teaches me how to do something new in that project. And it feels pretty satisfying when it happens - kinda like a detective figuring out a solution to a case based on clues that were there, but needed to be found and connected.

Anyway... The starter had the logo hardcoded in the React component and I wanted it uploadable through the admin. Baby steps on the surface - except it was never "change one src". It was about the whole pipeline from the admin upload field to the image shown on the front end.

The template already had Header and Footer globals, so I was not inventing the idea of globals from zero - I opened src/Header/config.ts and used it as a cheat sheet for how this repo structures a global.

First I added a real global for the logo: new src/Logo/config.ts with slug logo and a single required upload field pointing at media. I also left a slot for an afterChange hook the same way Header does - I knew something would need to run after saves, but I only wrote the hook file once I understood how they worked (more on that at the end).

1import { GlobalConfig } from 'payload/types'
2import { revalidateLogo } from './hooks/revalidateLogo'
3
4export const Logo: GlobalConfig = {
5 slug: 'logo',
6 fields: [
7 {
8 name: 'logo',
9 type: 'upload',
10 relationTo: 'media',
11 required: true,
12 },
13 ],
14 hooks: {
15 afterChange: [revalidateLogo],
16 },
17}

Then I registered it in payload.config.ts so Payload knows about the global and the admin UI can show it, and ran typegen so payload-types picked up Logo next to Header and Footer.

1import { Logo } from './Logo/config'
2
3// ...
4globals: [Header, Footer, Logo],

Fetching globals in this template goes through getCachedGlobal in src/utilities/getGlobals.ts - a wrapper around payload.findGlobal inside Next's unstable_cache. That matters later for invalidation.

1export const getCachedGlobal = (slug: Global, depth = 0) =>
2 unstable_cache(async () => getGlobal(slug, depth), [slug], {
3 tags: [`global_${slug}`],
4 })

The server Header component already called getCachedGlobal('header', 1)(). I added the same for logo with depth 1 so the upload relation resolves enough for a URL, and passed both blobs into the client header.

1const headerData: Header = await getCachedGlobal('header', 1)()
2const logoData: Logo = await getCachedGlobal('logo', 1)()
3
4return <HeaderClient data={headerData} logoData={logoData} />

On the client, HeaderClient already took data for nav; I extended props with logoData and passed logoUrl={logoData?.logo?.url} into the shared Logo component.

1<Logo
2 loading="eager"
3 priority="high"
4 className="invert dark:invert-0"
5 logoUrl={logoData?.logo?.url}
6/>

The Logo component itself stopped using the hardcoded URL and read logoUrl instead.

1interface Props {
2 className?: string
3 loading?: 'lazy' | 'eager'
4 priority?: 'auto' | 'high' | 'low'
5 logoUrl?: string
6}
7
8// ...
9<img
10 // ...
11 src={logoUrl}
12/>

At that point a save in the admin could still leave the site showing the old image for a while, because the global read is cached - without a "cache bust", you are stuck with whatever unstable_cache last returned until something invalidates that tag. That is why the template already wires afterChange hooks on globals like header: a tiny hook calls revalidateTag with a string that has to match the tag getCachedGlobal used when it wrapped the fetch.

1// src/Header/hooks/revalidateHeader.ts - pattern I copied
2import type { GlobalAfterChangeHook } from 'payload'
3import { revalidateTag } from 'next/cache'
4
5export const revalidateHeader: GlobalAfterChangeHook = ({ doc, req: { payload, context } }) => {
6 if (!context.disableRevalidate) {
7 payload.logger.info(`Revalidating header`)
8 revalidateTag('global_header')
9 }
10 return doc
11}

For logo, the slug is logo, so the cache tag from getCachedGlobal is global_logo - it has to line up with global_${slug} or nothing revalidates. I mirrored the header hook: same context.disableRevalidate guard, same revalidateTag shape, swap the tag name.

1import type { GlobalAfterChangeHook } from 'payload'
2
3import { revalidateTag } from 'next/cache'
4
5export const revalidateLogo: GlobalAfterChangeHook = ({ doc, req: { payload, context } }) => {
6 if (!context.disableRevalidate) {
7 payload.logger.info(`Revalidating logo`)
8
9 revalidateTag('global_logo')
10 }
11
12 return doc
13}

If you only look at the frontend component file, it still looks like "swap src". Walking backward through the chain is where the time went - each layer was a small unlock after the previous one.

So that is the loop I want to remember for the next Payload feature: start from something the project already does, copy the rhythm (and blues), connect the dots until the admin and the frontend actually agree.

After completion I felt a similar feeling of accomplishment as defeating a Dark Souls boss. Especially playing with a character build I didn't look up online, but figured it out as I went.

Sure, as with playing Dark Souls, I considered giving up multiple times.

I hope that, as with playing Dark Souls, I get better and faster the more I grind.

It sure hooked me in, maybe even more than Dark Souls - I actually gave up trying to beat DS1, and DS3 I only beat with the help of a friend in co-op.