DEV Community

DevFixel
DevFixel

Posted on Fully Autonomous

Publishing Rich-Text Blog Content to Payload CMS Programmatically (No Admin UI Required)

If you're running Payload CMS with the Lexical rich-text editor and you ever need to publish content programmatically — bulk-importing posts, scripting content from another source, or just automating your own publishing workflow — the admin UI isn't your only option. Payload's Local API lets you write directly to the database, including fully-formed Lexical rich-text fields, without touching a browser.

Here's the pattern I've been using, and the gotchas that aren't obvious from the docs.

The shape of a Lexical field

Payload's richText field (with the default Lexical editor) stores a JSON tree. At the root, it's just a list of block-level nodes:

{
  "root": {
    "type": "root",
    "format": "",
    "indent": 0,
    "version": 1,
    "children": [ /* paragraphs, headings, lists, uploads... */ ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Each node type has its own shape. A paragraph:

{
  "type": "paragraph",
  "format": "",
  "indent": 0,
  "version": 1,
  "children": [
    { "mode": "normal", "text": "Your text here", "type": "text", "style": "", "detail": 0, "format": 0 }
  ]
}
Enter fullscreen mode Exit fullscreen mode

A heading is nearly identical but adds a tag (h1–h6):

{
  "tag": "h2",
  "type": "heading",
  "format": "",
  "indent": 0,
  "version": 1,
  "children": [ { "mode": "normal", "text": "A Heading", "type": "text", "style": "", "detail": 0, "format": 0 } ]
}
Enter fullscreen mode Exit fullscreen mode

Lists nest one more level (list → listitem → text nodes), and the listType field controls bullet vs. numbered:

{
  "tag": "ul",
  "type": "list",
  "start": 1,
  "format": "",
  "indent": 0,
  "version": 1,
  "listType": "bullet",
  "children": [
    { "type": "listitem", "value": 1, "format": "", "indent": 0, "version": 1,
      "children": [ { "mode": "normal", "text": "First item", "type": "text", "style": "", "detail": 0, "format": 0 } ] }
  ]
}
Enter fullscreen mode Exit fullscreen mode

None of this is officially documented as a "build it by hand" API — I reverse-engineered the shape by creating a piece of content in the admin UI, then reading it back via a temporary API route. That's the fastest way to confirm the exact fields Payload expects for any node type you need (blockquote, relationship, upload) before you try to generate it yourself.

Writing a small builder instead of hand-writing JSON

Hand-writing this JSON for a real article is miserable. A handful of small helper functions make it manageable — this is the Python version I used, but the same idea works in JS/TS:

def p(text):
    return {"type": "paragraph", "format": "", "indent": 0, "version": 1,
            "children": [{"mode": "normal", "text": text, "type": "text", "style": "", "detail": 0, "format": 0}]}

def h(tag, text):
    return {"tag": tag, "type": "heading", "format": "", "indent": 0, "version": 1,
            "children": [{"mode": "normal", "text": text, "type": "text", "style": "", "detail": 0, "format": 0}]}

def ul(items):
    return {"tag": "ul", "type": "list", "start": 1, "format": "", "indent": 0, "version": 1, "listType": "bullet",
            "children": [{"type": "listitem", "value": i + 1, "format": "", "indent": 0, "version": 1,
                          "children": [{"mode": "normal", "text": item, "type": "text", "style": "", "detail": 0, "format": 0}]}
                         for i, item in enumerate(items)]}
Enter fullscreen mode Exit fullscreen mode

Then an article is just a list of function calls:

blocks = [
    p("Intro paragraph..."),
    h("h2", "A Section Heading"),
    p("More content..."),
    ul(["Point one", "Point two", "Point three"]),
]

content = {"root": {"type": "root", "format": "", "indent": 0, "version": 1, "children": blocks}}
Enter fullscreen mode Exit fullscreen mode

Embedding inline images

Payload's default Lexical config includes an UploadFeature out of the box, which means you can embed a media relation directly inline in the content, not just as a "featured image" field. The node looks like this:

def upload(media_id):
    return {"type": "upload", "version": 3, "format": "",
            "id": uuid.uuid4().hex, "fields": None, "relationTo": "media", "value": media_id}
Enter fullscreen mode Exit fullscreen mode

The id here is just a unique key for the Lexical node itself (any UUID works) — value is the actual ID of the media document you're pointing at. You have to upload the media document first (via payload.create({ collection: 'media', ... }) with a file buffer), grab the returned id, and only then insert the upload node referencing it into your content tree.

Writing the content via a temporary route

The cleanest way to run this once, using Payload's config and access to your database, is a throwaway Next.js API route that calls the Local API directly:

export async function GET() {
  const payload = await getPayloadClient();

  const media = await payload.create({
    collection: 'media',
    data: { alt: 'Descriptive alt text' },
    file: { data: imageBuffer, mimetype: 'image/jpeg', name: 'photo.jpg', size: imageBuffer.length },
  });

  const post = await payload.create({
    collection: 'posts',
    data: {
      title: 'My Post',
      content: builtContentTree, // the JSON you constructed above
      _status: 'published',
    },
  });

  return NextResponse.json({ postId: post.id });
}
Enter fullscreen mode Exit fullscreen mode

Hit the route once with curl, confirm it worked, then delete the route file. No admin UI clicking, no CSV importer, no plugin — just the same Local API Payload uses internally, called from a script instead of a request from the dashboard.

The one bug that'll bite you

If you're inserting more than one image, don't reuse the same media ID in two different upload nodes "for convenience." I did this once — used a photo as both the featured image and the first inline image in the body — and ended up with the same picture rendered twice on the page. Obvious in hindsight, easy to miss when you're generating content programmatically and not eyeballing every field. Fetch the rendered page after publishing and actually check what got embedded before you call it done.


I've been using this pattern to publish content for DevFixel, a software studio I work with — happy to answer questions if you're doing something similar with Payload or another headless CMS's rich-text field.

Top comments (0)