Last updated on

Collections, the part you write


Part 1 of 4 in the series The Schema I Didn't Write. Full series ↓

This site runs on PayloadCMS with Postgres on Neon, and for a long time I treated everything below payload.find() as a black box I was choosing not to open. I define a collection in TypeScript, an admin UI appears, queries work, and somewhere down there a database gets mutated on my behalf. That arrangement is fine until it isn't. Every genuinely scary moment I've had with this site happened in the layers I hadn't looked at.

So this is the first of four posts walking the whole stack, top to bottom: what a collection config actually controls (this post), what the ORM generates in Postgres, how migrations flow from my laptop through preview branches to prod, and where the bytes physically live on Neon. This one covers the only layer I actually write. It turns out that one TypeScript object is doing a lot more than describing fields.

One object, four outputs

A Payload collection is a TypeScript object. The one for blog posts, trimmed to its skeleton:

export const Posts: CollectionConfig = {
  slug: "posts",
  access: {
    read: ({ req: { user } }) => {
      if (user) return true;
      return { _status: { equals: "published" } };
    },
    create: requireAuthenticated,
    update: publishGatedUpdate,
    delete: requirePublisher,
  },
  versions: {
    maxPerDoc: 50,
    drafts: { schedulePublish: true },
  },
  fields: [
    { name: "title", type: "text", required: true },
    slugField(),
    { name: "description", type: "textarea", required: true },
    { name: "content", type: "richText", required: true /* Lexical */ },
    { name: "publishedAt", type: "date", index: true },
    { name: "category", type: "relationship", relationTo: "categories" },
    { name: "tags", type: "relationship", relationTo: "tags", hasMany: true },
    { name: "series", type: "join", collection: "series", on: "posts" },
    { name: "createdBy", type: "relationship", relationTo: "users", required: true },
  ],
};

From this one object Payload derives the admin UI, the REST and GraphQL APIs, the TypeScript types the frontend imports, and the database schema. I'll be honest: I'd half-forgotten the GraphQL one exists. It's mounted by the Payload scaffold at /api/graphql (a route file handles it, along with a playground), on by default, and it answers queries whether or not you ever think about it. That's exactly why the access rules in the next section matter more than they look. Even trivia you'd assume lives elsewhere traces back here: the order of collections in the config array is what drives the admin sidebar's nav order: there is no separate "order" setting, so the collections: [Pages, Posts, Categories, ...] line in the collection config file is secretly a UI decision.

The fields are a decent tour of Payload's type system by themselves. text and textarea are what they look like. richText is a whole embedded Lexical editor, extended on this site with custom blocks for code snippets, terminal transcripts, and Mermaid diagrams. relationship comes in single (category) and hasMany (tags) flavors, which (spoiler for part 2) produce completely different SQL. The series field is the odd one: a join field stores nothing at all. It's a read-only reverse view of Series.posts, so an editor viewing a post can see which series owns it without the membership being recorded twice.

Access rules that compile to WHERE clauses

The most interesting line in that skeleton is the read rule, because it doesn't return a boolean. For a logged-in user it returns true; for an anonymous request it returns a query constraint, and Payload folds that constraint into every read as if the caller had asked for it. Drafts don't leak to the public API because every anonymous query is silently rewritten to WHERE _status = 'published'. There's no middleware, no filter I have to remember in every route: the collection itself contributes the clause.

Once you see that trick, it shows up everywhere. Comments uses it so anonymous readers only see approved comments while a logged-in moderator sees the pending queue. Users uses it in both read and update, and the whole rule fits in a few lines:

export function usersOwnRecordAccess({ req: { user } }) {
  if (isAdmin(user)) return true;
  if (user) return { id: { equals: user.id } };
  return false;
}

An admin gets true; everyone else is scoped to their own account, which both locks the API and filters the admin list view for free; anonymous gets nothing. And because the rule lives on the collection, it governs every surface at once. I checked while writing this: an anonymous GraphQL query for posts returns exactly the published ones: same clause, same enforcement, an API I'd forgotten about protected by a rule I wrote for a different one.

The unnerving lesson in this area came from what happens when you don't write these rules. Payload's default for every operation is "any authenticated user" (not public, but much looser than it sounds once you have multiple roles). Left on defaults, any signed-in author could have read every user's email and PATCH'd another user's password (including an admin's, a clean account takeover), or flipped their own comment to approved, bypassing moderation entirely. The audit that found this ended with every collection declaring all four operations explicitly, plus a regression test that fails if a collection ever ships without stating its write access on purpose.

The publish gate, and why it needs both a belt and braces

Users carry a roleadmin, editor, or author), and the editorial rule is simple: admins and editors are approvers who can publish, unpublish, and delete; authors are draft-only contributors. The implementation is clever in one place and subtle in another, and the difference between the two is the part worth writing down.

The clever part is the update access rule:

export const publishGateFor = (user: RequestUser): boolean | Where => {
  if (!user) return false;
  if (canPublish(user)) return true;
  return { _status: { equals: "draft" } };
};

An author's update access is a constraint that can never match a published document. So Payload concludes an author can't update published docs, and hides the Publish button for them in the admin UI. Role-based UI behavior, driven entirely by an access rule.

The subtle part: that's not actually enforcement. An access filter constrains which documents you may touch, based on their current state. It never inspects the incoming data. So on its own, nothing stops an author from taking a draft they're allowed to edit and writing _status: "published" into it through the REST API. The real gate is a beforeChange hook that examines the incoming write itself:

export const enforcePublishGate = ({ data, req }: PublishGateHookArgs): void => {
  if (publishGateViolation(req.user, data?._status)) {
    throw new Forbidden(req.t);
  }
};

The access rule is UX; the hook is security. It took me a while to internalize that those are different jobs and Payload gives you different tools for each.

Role systems also have bootstrap problems. Only admins may assign roles (field-level access on role, otherwise an author could promote themselves), and new accounts default to author. Which means the first account on a fresh install would be an author, with no admin in existence to ever promote anyone: a permanent deadlock baked into an otherwise sensible pair of rules. The fix is a hook that promotes the first-ever account to admin. Its mirror image guards the other end. You can't delete or demote the last remaining admin, because an admin-less install would re-expose Payload's unauthenticated create-first-user endpoint to whoever found it first.

Hooks are where the collection becomes behavior

Everything above is still mostly description. The hooks block is where the collection starts doing things.

The gentle end of the spectrum: slugField() is a small factory whose beforeValidate hook derives a URL slug from the title when the field is left blank (lowercased, quotes stripped, non-alphanumerics collapsed to dashes). Editors never think about URLs, but can override one. A beforeChange hook stamps publishedAt the first time a post is published, kept separate from createdAt because a scheduled post can be drafted weeks before it goes live.

The sharp end is cache invalidation. This site statically renders pages, so publishing a post has to actively refresh the blog index, the homepage, the post's own URL, its category and tag archives, and if the post is in a series, every sibling post, because the series banner on each of them lists the others. All of that fans out from an afterChange hook. Trimmed to its skeleton:

const revalidatePost: CollectionAfterChangeHook = async ({ doc, previousDoc, req }) => {
  const isPublished = doc._status === "published";
  const wasPublished = previousDoc?._status === "published";

  // Draft-only change with no published side: nothing public to refresh.
  if (!isPublished && !wasPublished) return doc;

  try {
    revalidatePath("/blog");
    revalidatePath("/");
    revalidatePath(`/blog/${doc.slug}`);
    // ...old slug if it changed, series siblings, category/tag archives...
  } catch (error) {
    console.error("revalidatePost: failed to revalidate", error);
  }
  return doc;
};

The two least obvious lines are the ones doing the most work:

  • The early return means the cache work only fires when the post's published visibility actually changed. Draft-to-draft saves skip it entirely: a draft edit must never touch the live cache.
  • The try/catch exists because revalidatePath only works inside a Next.js request context. Called from a standalone script (the seeder, say) it throws. And a failed revalidation should never fail the save it was reacting to.

Deletion hooks taught me about ordering. Refreshing a deleted post's series siblings has to happen in beforeDelete, not afterDelete: after the delete, the membership row is already gone and there's no way to discover which series the post was in. And a second beforeDelete hook removes the post's comments before the post itself. Why that's forced rather than chosen is a story about the generated SQL: it's the opening act of part 2.

The one line that changes everything

versions: { drafts: { schedulePublish: true } } is the most consequential line in the config. The drafts part gives every post a draft/published status, version history in the admin (capped at 50 per document so the version table doesn't grow unbounded), and everything the access rules above gate on: _status doesn't exist without it.

The schedulePublish part is one boolean that quietly provisions infrastructure. It registers a background job type, which makes Payload mount a jobs-runner endpoint at /api/payload-jobs/run: whose access defaults to allow-all, so anyone who found the URL could have triggered the queue. Gating it behind a secret, and adding the Vercel cron that actually hits it on a schedule, were both my job. "Editor picks a future publish time" turns out to mean: a job row, a guarded HTTP endpoint, and a cron, none of which appear anywhere in the collection config that caused them to exist.

The generated types close the loop

The last output of this layer is the generated types file: run payload generate:types and the whole config compiles to just under a thousand lines of TypeScript interfaces, which the entire frontend imports. Post, Comment, SiteSetting: every query result is typed by generated code.

The ripple effects are total in a way I didn't expect. Add an admin.description to a field (pure UI sugar, you'd think), and the generated file changes, because Payload emits the description as JSDoc on the interface property. CI checks the generated files against the config on every PR, so forgetting to regenerate fails the build. That check annoyed me exactly once; now I appreciate what it buys: the types physically cannot drift from the config, because a human never edits them and a machine always rechecks them.

That's the layer you write: one object that turns out to encode a security policy, an editorial workflow, a cache strategy, and a type system. Next up: what happens when Payload hands this object to the ORM, and the Postgres schema that falls out of it. That's where the comment-deletion hook stops being a design choice and becomes a foreign-key ultimatum.

Comments

No comments yet. Be the first to comment.

Leave a comment