The post was live. I could see it in the browser — my own words, my own URL, rendering on the actual production site. I had just written a 560-line script to convert Markdown into Payload CMS's Lexical JSON format, done a dry run, confirmed the structure looked right, and pulled the trigger.
Then I opened the admin panel to check how it looked.
Nothing. Not the post. Not a draft. Not even a deleted entry. I refreshed. Checked the wrong collection. Checked filters. Checked if I was logged into the right account. Still nothing. The post was alive on the internet and invisible to the person who built it.
That's when I realized I had no idea how Payload actually worked.
What I was building
A publishing pipeline for this blog. The setup is: I write in Markdown, an automated routine ships the file to a TypeScript converter, the converter transforms the Markdown AST into Payload's native Lexical JSON, and the result gets inserted into a Neon Postgres database. The portfolio site — a Next.js app — reads from that same database and renders the posts. No deploy steps. No manual upload. Write, convert, insert, done.
Simple. Obvious. Straightforward.
My first version of the insert step used raw SQL. A direct INSERT INTO posts. I had the Drizzle-generated schema right there, I knew the column names, I knew the exact shape of the Lexical JSON I was generating. I could see what the existing rows looked like. What was the point of going through an abstraction when the destination was just a table?
The ghost post was the point.
The invisible row
Payload doesn't just insert a row into posts when you publish something. It also inserts a corresponding row into _posts_v — a version history table. Every save, every auto-save, every publish event writes there. That table is how Payload tracks "published", "draft", "previous version". It's what the admin UI joins against when building the document list for a collection.
I found this out by querying the database directly. I could see id=9 sitting in the posts table, _status set to published, all the Lexical JSON intact. Then I queried _posts_v filtered to post_id=9.
Zero rows.
My raw SQL insert wrote to posts. It did not touch _posts_v. No version rows. No history entry. As far as Payload was concerned, this post had never been saved — not even once — through any legitimate path.
The admin UI query filters the collection by finding entries with a published version row. No version row? The join returns nothing. The post doesn't exist as far as the admin is concerned. But the portfolio queries posts directly. That row is there. The post renders perfectly fine for any visitor.
Live on the internet. Invisible to me.
It's like submitting a form directly to a company's database and skipping their internal ticketing system. Your record exists in the tables. Nobody in the building has any idea.
The fix, and what it clarified
Delete the ghost via a direct SQL transaction (it wasn't accessible through the admin to delete normally), rewrite the publish step to use Payload's Local API — payload.create() instead of a raw insert — and re-run.
The new post had a _posts_v row immediately. One version, status published. Appeared in the admin list on first load.
The fix took fifteen minutes. The understanding took longer.
The lesson isn't "use the API, not raw SQL." That's the surface version. The actual lesson is: when a framework has an API layer, the API layer is the contract — not the database schema.
Payload's version history, its draft system, its relationship tracking — none of that lives in the schema alone. It lives in the application layer that sits on top of the schema. The schema is a side-effect of what the API decided. The API is the source of truth.
This is why every CMS documentation page that mentions programmatic access tells you to use their SDK or their official API. It's not just about convenience. The application layer maintains invariants that the database layer cannot enforce by itself. A foreign key constraint can't know that a published post needs a version row. Only the code that understands what "publishing a post" means can uphold that invariant — and that code is the API.
The honest part
Raw SQL isn't wrong. There are situations where you absolutely should bypass the API layer: bulk migrations, analytics queries, one-off data repairs that would be prohibitively slow or awkward through the API. The Payload docs explicitly describe the Local API and direct DB access as complementary, not competing.
And the instinct to write directly to the database isn't a bad instinct. It feels more direct. More honest. You can see exactly what you're doing without trusting a black box to do the right thing in the right order.
The problem is specifically about writes through a framework that owns a consistency model you don't fully understand yet. And when you're early enough in a codebase that you don't know whether the framework even has a consistency model, that's the exact moment to reach for the official API first and raw SQL only when there's a specific, narrow reason.
I hadn't read enough of the Payload internals to know _posts_v existed. That's a gap in my knowledge, not a design flaw in the framework.
Both can be true, weirdly
There's a tempting version of this story where the moral is "abstractions are good, actually" and another where it's "read the docs before you hack." Both are partly right and both miss something.
The deeper pattern is: every non-trivial persistence layer has some concept of managed state that doesn't live cleanly in the raw schema. Payload has version rows. Supabase has auth metadata spread across private schema tables. Django has content type tables for generic relations. WordPress has postmeta. You can't know all of this upfront — you discover it when something breaks in a weird way.
The tell is a specific kind of breakage: the data is present but the system can't see it. The bytes are in the right place. The count looks right. But a query that goes through the application layer returns nothing. That's almost always a sign you wrote around an abstraction that owned something you didn't account for.
Databases store bytes. Frameworks own meaning. When the bytes are correct but the meaning is broken, you bypassed the layer that holds the meaning.
That's not a crisis. It's a map. Once you know where the meaning lives, you know where to write.
Somewhere after id=9
The ghost post is deleted. The pipeline works. Post id=10 has its version row and renders fine in the admin.
I learned more about Payload's internals in the twenty minutes I spent debugging one missing row than I did reading documentation for an hour. That's not a complaint about the docs — it's just how this goes. You can't fully understand a system by reading about it. You understand it by breaking it and then going to find what broke.
The post that couldn't be edited is, in some sense, this one.
Don't write around the API until you know what the API is writing for you.