Making Code Blocks Look Like Code
One of the posts already sitting in this blog had a code sample in it that looked like this:
ts
// src/globals/SiteSettings.ts export const SiteSettings: GlobalConfig = { ... }
That's not a formatting choice. That's what happens when you paste a fenced code block into a WYSIWYG editor that has no idea what a fenced code block is: the language tag becomes a stray paragraph, the code becomes plain serif text at the same size as everything around it, and the whole thing reads less like a code sample and more like a typo you forgot to delete.
Payload's rich text editor (Lexical, under the hood) ships with an inline code feature — wrap a word in backticks-equivalent formatting and you get like this. What it doesn't ship with is a block-level, multi-line, syntax-highlighted code block. For a blog that's mostly about code, that's a gap that was always going to need filling. This is the post about filling it.
What Payload gives you, and what it doesn't
Lexical's default feature set (InlineCodeFeature) covers exactly one case: a run of text formatted as code, inline, no coloring, no language awareness. It's a text-format flag, the same category as bold or italic. That's genuinely fine for useState() or pnpm install sitting in the middle of a sentence.
It has nothing for the other case — ten lines of TypeScript that need their own block, their own background, and colored tokens so the shape of the code is legible at a glance. Nothing in Payload's core editor produces that. You have to build it.
The mechanism: a droppable block, not a new node type
Lexical (the underlying framework) supports writing entirely custom node types, but Payload gives you something better-suited to this: BlocksFeature. It lets you register an ordinary Payload Block — the same config shape you'd use for a page-builder section — as something droppable directly into a rich text field's content stream, from the editor's own insert-block UI. The author picks "Code Snippet" from a menu, fills in a couple of fields, and it sits inline with the surrounding paragraphs like any other block.
This wasn't a new pattern for this codebase — it was a second use of an existing one. Pages already had a socialLinksList block registered the same way, for a completely unrelated reason (dropping the site's social links mid-content on a page). Code snippets just needed the same plumbing pointed at a different block:
export const CodeSnippetBlock: Block = {
slug: "codeSnippet",
interfaceName: "CodeSnippetBlock",
labels: {
singular: "Code Snippet",
plural: "Code Snippets",
},
fields: [
{
name: "language",
type: "select",
required: true,
defaultValue: "javascript",
options: CODE_BLOCK_LANGUAGES.map(({ label, value }) => ({
label,
value,
})),
},
{
name: "code",
type: "code",
required: true,
admin: {
language: "javascript",
},
},
],
};Two fields: a language dropdown, and a code field — Payload's built-in code-editor field type (CodeMirror under the hood), so at least typing a snippet in the admin UI feels like typing code, not filling out a form. That field's admin.language only affects the editing experience, though — it's fixed to javascript regardless of what the post is actually about, because it's cosmetic. The language select above it is the one that actually controls what ships to readers.

That CodeMirror coloring is happening entirely inside the admin UI, before anything is even saved — a separate highlighter from Shiki, running client-side for editing feedback only. It's not what readers see; it's what makes writing one of these feel like writing code instead of filling out a form.
Registering the block only on Posts (not Pages) mirrors the same reasoning socialLinksList used in reverse: a mid-article code sample is a blog-post thing, not a page-layout thing.
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
BlocksFeature({ blocks: [CodeSnippetBlock] }),
],
}),One list, three consumers
The language dropdown needs a list of options. The highlighter needs to know which languages to actually load. And rendering needs a safe fallback for whatever a post's language field says, in case it's ever empty, stale, or just wrong. Three different concerns, but they all have to agree with each other, or the dropdown could offer a language the highlighter never loaded.
So there's exactly one list, and everything else derives from it:
export const CODE_BLOCK_LANGUAGES = [
{ label: "JavaScript", value: "javascript" },
{ label: "TypeScript", value: "typescript" },
{ label: "JSX", value: "jsx" },
{ label: "TSX", value: "tsx" },
{ label: "JSON", value: "json" },
{ label: "Bash", value: "bash" },
{ label: "CSS", value: "css" },
{ label: "HTML", value: "html" },
{ label: "Python", value: "python" },
{ label: "SQL", value: "sql" },
{ label: "YAML", value: "yaml" },
{ label: "Markdown", value: "markdown" },
{ label: "Plain text", value: "text" },
] as const;
export function resolveLanguage(value: string | null | undefined) {
if (value && SUPPORTED_LANGUAGES.has(value)) return value;
return "text";
}resolveLanguage matters more than it looks. The highlighter throws if you ask it for a language it hasn't loaded — and dropdown options can, in principle, change or get removed later while old posts still reference them. Falling back to plain, unhighlighted text instead of throwing means a stale or unrecognized language value degrades one code block, not the entire page it's on.
Where the highlighting actually happens
This is the decision that mattered most, and it came down to one fact about this site: every post is fully static. blog/[slug]/page.tsx prerenders every post at build time via generateStaticParams() — there's no per-request rendering happening for a published post, ever.
Given that, there were two real options for turning a code string into colored tokens. Run a highlighter in the visitor's browser after the page loads (what a library like Prism or react-syntax-highlighter does) — which means shipping JS to every visitor, on every post, to do work that produces the exact same output every single time. Or run it once, on the server, at build time, and ship the already-colored HTML — no highlighting JS in the bundle at all, because there's nothing left to compute once a page exists.
For a static site, the second option isn't a stylistic preference, it's just strictly better: same visual result, zero added client-side cost.
Shiki is the tool for that half of the job — a highlighter that runs in Node, not the browser, and hands back a finished <pre class="shiki"> string with the theme's colors already baked in as inline styles. It's also the same highlighter Astro reaches for by default on its own markdown code fences, for exactly this reason — build-time highlighting is the obvious right call for content that doesn't change per visitor, whichever framework is doing the building.
const THEME = "github-dark";
let highlighterPromise: Promise<Highlighter> | null = null;
function getHighlighter(): Promise<Highlighter> {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
themes: [THEME],
langs: CODE_BLOCK_LANGUAGES.map((language) => language.value),
});
}
return highlighterPromise;
}
export async function highlightCode(code: string, language: string | null | undefined) {
const highlighter = await getHighlighter();
return highlighter.codeToHtml(code, {
lang: resolveLanguage(language),
theme: THEME,
});
}The module-scoped highlighterPromise is doing real work here, not just tidiness. Creating a Shiki highlighter loads grammar and theme data — real cost, paid once. Every code block on every post reuses the same instance instead of paying that cost per snippet. Since the whole site is static, "the whole build" and "every request that will ever happen for this content" are the same thing, so this genuinely only runs once, total.
Rendering it: an async component, no client JS
React Server Components can be async functions directly — no special data-fetching pattern needed, no useEffect, no loading state. That's what makes the server-side-highlighting approach clean to wire up:
export default async function CodeSnippet({
code,
language,
}: {
code: string;
language: string;
}) {
const html = await highlightCode(code, language);
return (
<div className="code-snippet" dangerouslySetInnerHTML={{ __html: html }} />
);
}dangerouslySetInnerHTML looks alarming out of context, but there's nothing dangerous about it here: the HTML being injected is Shiki's own output, generated from the post's own stored content, and Shiki HTML-escapes the source text internally before wrapping it in colored spans. It's not arbitrary or user-submitted markup at request time — it's the same content an editor already saved through Payload's own validation.
Wiring the block to this component happens in the same place socialLinksList gets wired to its component — the JSX converters shared by every rich text field on the site:
export const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({
...defaultConverters,
blocks: {
socialLinksList: () => <SocialLinksList mode="text" />,
codeSnippet: ({ node }) => {
const fields = node.fields as CodeSnippetFields;
return <CodeSnippet code={fields.code} language={fields.language} />;
},
},
});The CSS is smaller than you'd expect
Shiki's output already carries the theme's background and text colors as inline styles on the <pre> tag itself — that part needed no CSS at all. What it doesn't carry is layout: spacing, a horizontal scrollbar for long lines, a monospace font. This site had essentially no prose styling before this (no CSS framework, a nearly-empty stylesheet), so without at least this much, a long line would have just overflowed the page.
.code-snippet {
margin: 1.5em 0;
border-radius: 6px;
overflow: hidden;
}
.code-snippet pre {
margin: 0;
padding: 1em;
overflow-x: auto;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.875rem;
line-height: 1.6;
}The border-radius sits on the wrapping .code-snippet div, not the pre — Shiki's inline background is a plain rectangle, so rounding the wrapper and clipping (overflow: hidden) is what makes the corners actually look rounded instead of the background poking out past them.
Retrofitting it onto a post that already existed
The real test wasn't a fresh post — it was going back into "Collections and globals," a section that already had that broken ts-then-plain-text artifact sitting in it, and replacing it with an actual Code Snippet block. Same content, same TypeScript, same file-path comment. The only thing that changed was which kind of block was holding it.
Before, it read like a formatting mistake nobody caught. After, it reads like code — dark background, monospace, keywords and types and string literals each their own color, everything a reader's eye already knows how to parse from every other code block they've ever looked at.
What this doesn't cover
Scoped to Posts, not Pages — same reasoning socialLinksList used the other direction, and easy to extend later if a page ever genuinely needs a code sample. Thirteen preloaded languages, chosen for what this blog is actually likely to write about, not exhaustively; anything outside that list renders as plain, unhighlighted text rather than failing, which was a deliberate tradeoff (see resolveLanguage above) — better a dull code block than a broken page.
Testing is limited to what's actually testable without a component-rendering harness this project doesn't have yet: resolveLanguage is a pure function, so it gets a real unit test — every supported language resolves to itself, everything else falls back to text. The Shiki-highlighting path itself is exercised by hand, not by an automated test, which is an honest gap rather than an oversight. If this codebase ever adds real component-rendering tests, that's the obvious next thing to cover.