Better OG images from CMS fields
How to generate unique Open Graph images for every page using Payload CMS fields and Next.js dynamic OG routes, so shared links get the r...
The Blog
Payload's block picker shows names. With 27 layout blocks, editors were guessing. We wrote a script that screenshots every block from the live site and outputs a thumbnail, one command, always current.
The problem
Payload's block editor accepts an imageURL per block type. Set one and editors see a thumbnail in the picker. Leave it out and they see a list of names, "Directional List," "Radial Cards," "Content Block", and have to guess what each one looks like.
We have 27 layout blocks. Once the library got past ten or so, the name-only picker became a friction point. Editors would add a block, realise it wasn't what they wanted, delete it, and try another. Or they'd stick with the three blocks they recognised and ignore the rest.
The block library is only useful if editors can find what they need. Thumbnails solve this, but 27 blocks is a lot of images to create and maintain.
The approach
We considered creating thumbnails in Figma. The problem is maintenance. Every time a block's design changes, new spacing, a different font size, an updated colour, someone has to remember to update the mockup. Nobody does. The thumbnail drifts from reality and editors are back to guessing.
Real screenshots are always current. Run the command after a deploy and the thumbnails reflect exactly what visitors see. If a block looks different on the site, it looks different in the picker.
The command is pnpm thumbnails:blocks. It screenshots every block from the live site and outputs a framed WebP thumbnail. The whole set takes a few minutes. The result is 27 thumbnails, each 2–17 KB, stored at public/blocks/<slug>.webp.
Each block config references its thumbnail:
json1// src/blocks/ContentBlock/config.ts2{3 slug: 'contentBlock',4 imageURL: '/blocks/contentBlock.webp',5 // ...fields6}The script
The script lives at scripts/generate-block-thumbnails.mjs. It does four things: finds every block type, finds a published page that uses each one, screenshots the block, and frames it as a thumbnail.
The script reads the blockComponents map from RenderBlocks.tsx, the same map the frontend uses to render blocks on the page. Every block that can render on the site gets picked up automatically. Add a new block to the map, it's included next time. Remove one, it's excluded.
It queries the Payload REST API for published pages, work entries, and posts, depth: 0, selecting only slug, layout, and breadcrumbs. It walks every document's layout and records which block types appear and where. Pages are tried first since they tend to have the richest content.
If a block type has no published usage anywhere on the site, it's skipped. The script lists these at the end of the run, which doubles as a way to spot blocks nobody is using.
Playwright opens the page in headless Chrome at 1440×960. It hides the fixed header and scrolls the entire page top to bottom so scroll-triggered animations run and lazy images load.
It finds the block by its index among the children of .payload-blocks, then measures the visible content area. A block's bounding box isn't always what you'd expect, parallax effects push content outside the overflow boundary, and overlapping layouts bleed into neighbouring blocks. The script ignores anything a parent clips with overflow: hidden and caps overflow at 120 pixels. It waits for images inside the block to load, then takes the screenshot with a maximum height of 1100 pixels.
The raw screenshot goes through sharp. It adds a 64-pixel band above and below, insets the image on the site's Light background colour (#F7F7F5), and outputs a 480×320 WebP at quality 80.
Usage
You don't have to regenerate all 27 every time. Pass block slugs as arguments to regenerate only the ones you name:
bash1pnpm thumbnails:blocks contentBlock teamBlockThe script targets the live site by default — real published content with production media, exactly as visitors see it. You can point it at a different environment with BASE_URL:
bash1BASE_URL=https://staging.littledash.com.au pnpm thumbnails:blocksA local build works too as long as the media is available locally, but the live site is the most reliable source because our media is stored in S3.
Edge cases
Transparent page body. The site's background colour is set at a higher level, so block screenshots come back with a transparent background. The sharp step fills this in with #F7F7F5 when it creates the framed thumbnail. Without this, the thumbnails would have a checkerboard or black background in the admin panel.
Parallax and overlapping layouts. Some blocks intentionally overflow their container, a parallax hero that moves on scroll, or a section with negative margin pulling it into the block above. The 120-pixel overflow cap and overflow-clip detection handle most of these. The result isn't pixel-perfect for every block, but it's accurate enough for a picker thumbnail.
Blocks with no published usage. If a block type exists in the code but isn't used on any published page, there's nothing to screenshot. The script lists these at the end. If a block has been in the codebase for months with zero usage, it's worth asking whether it should stay.
Your project
The script is specific to our setup, but the pattern is portable. Here's what you'd change for your own Payload project.
The block map source, we read blockComponents from RenderBlocks.tsx. Your project might structure this as a barrel export, a config object, or a dynamic import map. The script just needs a list of block slugs.
The collections to query, we query pages, work, and posts. Your project might have different collection slugs, different layout field names, or nested layouts in tabs or groups.
The container selector, we use .payload-blocks. If your frontend wraps blocks in a different element, update the selector.
The background colour and output dimensions, we use #F7F7F5 and 480×320. Use whatever fits your site and admin panel.
The core idea is the same: query the API for real content, screenshot the block from a real page, crop to the visible bounds, and frame it as a thumbnail. One command, always current.