Production Payload CMS on AWS — 5-Part Series
Part 1: Architecture | Part 2: Database | Part 3: Docker | Part 4: ALB Routing | Part 5: Lexical Frontend (this post)
Lexical is Meta's open-source rich text editor framework — used by Facebook, WhatsApp, and since Payload CMS 3.x, as the default editor for all richText fields. It replaced Slate.js from Payload 2.x.
Lexical stores content as a JSON tree, not HTML. When you retrieve a richText field from the Payload REST API, you get a nested node structure. To render it on your Astro frontend, you need to walk that tree and convert each node to HTML. This part gives you the complete, production-ready implementation.
What the Lexical JSON Looks Like
{
"root": {
"type": "root",
"children": [
{
"type": "heading",
"tag": "h2",
"children": [
{ "type": "text", "text": "About Our Department", "format": 0 }
]
},
{
"type": "paragraph",
"children": [
{ "type": "text", "text": "The ", "format": 0 },
{ "type": "text", "text": "Sheriff's Office", "format": 1 },
{ "type": "text", "text": " serves all residents.", "format": 0 }
]
}
]
}
} The Text Format Bitmask
Text nodes use a bitmask integer to encode formatting. format: 3 means bold (1) AND italic (2) — check each bit with format & mask.
| Bit | Value | Format |
|---|---|---|
| 0 | 1 | Bold |
| 1 | 2 | Italic |
| 2 | 4 | Strikethrough |
| 3 | 8 | Underline |
| 4 | 16 | Code |
| 5 | 32 | Subscript |
| 6 | 64 | Superscript |
Step 1: The Lexical Renderer
Create src/lib/lexical.ts in your Astro project:
/**
* Lexical JSON to HTML renderer
* Compatible with @payloadcms/richtext-lexical 3.x
*/
export function lexicalToHtml(content: any): string {
if (!content?.root?.children) return ''
return renderNodes(content.root.children)
}
function renderNodes(nodes: any[]): string {
if (!Array.isArray(nodes)) return ''
return nodes.map(renderNode).join('')
}
function renderNode(node: any): string {
if (!node) return ''
if (node.type === 'text') {
let text = escapeHtml(node.text || '')
const fmt = node.format || 0
if (fmt & 1) text = `<strong>${text}</strong>`
if (fmt & 2) text = `<em>${text}</em>`
if (fmt & 4) text = `<s>${text}</s>`
if (fmt & 8) text = `<u>${text}</u>`
if (fmt & 16) text = `<code>${text}</code>`
if (fmt & 32) text = `<sub>${text}</sub>`
if (fmt & 64) text = `<sup>${text}</sup>`
return text
}
const children = node.children ? renderNodes(node.children) : ''
switch (node.type) {
case 'root': return children
case 'paragraph': return `<p>${children || ' '}</p>`
case 'heading':
const tag = node.tag || 'h2'
return `<${tag}>${children}</${tag}>`
case 'list':
const listTag = node.listType === 'bullet' ? 'ul' : 'ol'
return `<${listTag}>${children}</${listTag}>`
case 'listitem': return `<li>${children}</li>`
case 'link': {
const url = node.fields?.url || node.url || '#'
const newTab = node.fields?.newTab || node.newTab || false
const target = newTab ? ' target="_blank" rel="noopener noreferrer"' : ''
return `<a href="${escapeAttr(url)}"${target}>${children}</a>`
}
case 'quote': return `<blockquote>${children}</blockquote>`
case 'horizontalrule': return '<hr/>'
case 'linebreak': return '<br/>'
case 'upload': {
const value = node.value
if (!value) return ''
const mimeType = value.mimeType || ''
if (mimeType.startsWith('image/')) {
const src = escapeAttr(value.url || '')
const alt = escapeAttr(value.alt || value.filename || '')
const width = value.width ? ` width="${value.width}"` : ''
const height = value.height ? ` height="${value.height}"` : ''
return `<figure class="rich-image">
<img src="${src}" alt="${alt}"${width}${height} loading="lazy"/>
${value.caption ? `<figcaption>${escapeHtml(value.caption)}</figcaption>` : ''}
</figure>`
}
const filename = escapeHtml(value.filename || 'Download')
const url = escapeAttr(value.url || '#')
return `<a href="${url}" class="rich-download" download>${filename}</a>`
}
default: return children
}
}
function escapeHtml(str: string): string {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
function escapeAttr(str: string): string {
return String(str).replace(/"/g, '"').replace(/'/g, ''')
} Step 2: Use It in Astro Pages
---
import { lexicalToHtml } from '../../lib/lexical';
const { id } = Astro.params;
let item: any = null;
try {
const PAYLOAD_URL = import.meta.env.PAYLOAD_INTERNAL_URL || 'http://localhost:3000';
const res = await fetch(`${PAYLOAD_URL}/api/your-collection/${id}?depth=1`);
if (res.ok) item = await res.json();
} catch(e) {}
if (!item) return Astro.redirect('/your-collection');
const bodyHtml = lexicalToHtml(item.body);
---
<div class="rich-content" set:html={bodyHtml} /> Step 3: The WHERE Query Format
This is the second most common silent failure in Payload REST API integrations. WHERE queries use bracket notation — not JSON encoding.
// Correct — bracket notation
`/api/pages?where[status][equals]=published`
`/api/pages?where[status][equals]=published&where[slug][equals]=about`
`/api/pages?where[status][equals]=published&sort=-createdAt&limit=10`
// Wrong — returns empty results silently
`/api/pages?where={"status":{"equals":"published"}}`
`/api/pages?where.status.equals=published` Step 4: The Reusable Query Builder
Create src/lib/payload.ts:
const PAYLOAD_URL = import.meta.env.PAYLOAD_INTERNAL_URL || 'http://localhost:3000'
export async function fetchCollection(
collection: string,
params: {
where?: Record<string, any>
sort?: string
limit?: number
depth?: number
page?: number
} = {}
) {
const parts: string[] = []
if (params.where) flattenWhere(params.where, 'where', parts)
if (params.sort) parts.push(`sort=${encodeURIComponent(params.sort)}`)
if (params.limit) parts.push(`limit=${params.limit}`)
if (params.depth !== undefined) parts.push(`depth=${params.depth}`)
if (params.page) parts.push(`page=${params.page}`)
const qs = parts.join('&')
const url = `${PAYLOAD_URL}/api/${collection}${qs ? '?' + qs : ''}`
try {
const res = await fetch(url)
if (!res.ok) return null
return res.json()
} catch(e) {
console.error(`[payload] fetch failed: ${url}`, e)
return null
}
}
function flattenWhere(obj: any, prefix: string, parts: string[]) {
for (const [key, val] of Object.entries(obj)) {
if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
flattenWhere(val, `${prefix}[${key}]`, parts)
} else {
parts.push(`${prefix}[${key}]=${encodeURIComponent(String(val))}`)
}
}
}
// Ready-to-use helpers
export const getPublishedPages = () =>
fetchCollection('pages', {
where: { status: { equals: 'published' } },
sort: 'title',
limit: 50,
})
export const getPageBySlug = (slug: string) =>
fetchCollection('pages', {
where: { slug: { equals: slug }, status: { equals: 'published' } },
limit: 1,
depth: 1,
})
export const getPublicNotices = (limit = 10) =>
fetchCollection('public-notices', {
where: { status: { equals: 'published' } },
sort: '-noticeDate',
limit,
})
export const getDepartments = (limit = 50) =>
fetchCollection('departments', {
where: { status: { equals: 'published' } },
sort: 'name',
limit,
}) Step 5: Depth and Type Safety
Use depth=1 for most queries — it populates one level of relationships without ballooning response size. Use depth=2 only when you need data that is two relationships deep.
# Generate TypeScript types from your collections
cd payload
npm run generate:types Copy payload-types.ts to your frontend for autocomplete and type safety:
import type { Page, Department, PublicNotice } from '../types/payload-types'
const page = data.docs[0] as Page
const content = page.content // TypeScript knows this is Lexical JSON Complete Series Recap
| Part | What You Built |
|---|---|
| Part 1 | Architecture — ECS Fargate, ALB routing design, cost model |
| Part 2 | Data layer — Aurora PostgreSQL, dedicated DB user, Secrets Manager |
| Part 3 | Containers — Dockerfile, importMap fix, layout gotchas, ECR push |
| Part 4 | Routing — ALB rules, target groups, internal URL pattern |
| Part 5 | Frontend — Lexical renderer, WHERE query format, type safety |
The Eight Undocumented Problems This Series Fills
When WAM DevTech built this stack in production, these were the problems that were not documented anywhere:
- importMap population — no official docs on manual population for CI/CD environments
- rsc exports —
RscEntryLexicalFieldrequirement is not in the Payload docs - RichTextCell removal — breaking change with no migration guide
- Root layout restriction — the
<html><body>hydration error is mentioned nowhere - serverFunction vs serverFunctions — prop name mismatch between versions
- ALB rule priority ordering —
/_next/*must precede the default rule - WHERE bracket notation — the REST API docs show examples but do not warn about JSON encoding failures
- Health check matcher 200-404 — needed during Payload startup, not documented for ECS deployments
The complete working implementation of this architecture — deployed for Chippewa County, Michigan's government website in 5 days from an RFP document alone — is documented in the case study below.