Last updated on

How This Blog's Content Model Actually Works


The first post in this series ("Starting Full-Stack From What I Already Know") ended with a promise: a follow-up on what the content model actually looks like, now that Payload is mounted directly inside Next instead of sitting behind a REST API for a separate Astro frontend to fetch from. This is that post.

Skip the "why one app" argument here — that's covered in the README and the earlier posts. This one is about the shape of the data and the two design decisions that turned out to matter more than the framework choice itself.

Collections and globals

Payload has two kinds of content containers, and the distinction is more useful than it sounds. A collection is a list of many documents — posts, pages, media. A global is exactly one record, ever — there's no "create new" button for it. This site has one: site-settings.

The tell for which one you want: if you'd ever paginate it, it's a collection. Social links aren't a list of things you create instances of — they're one record (this site's settings) that happens to contain an array field. Getting this wrong the first time round would have meant a site-settings collection with a permanent count of one document, an admin list view that only ever shows one row, and relationship fields pointing at "the one site-settings document" everywhere instead of a direct findGlobal call. Small thing, but it's the kind of modeling decision that's cheap to get right early and mildly annoying to unwind later.


// src/globals/SiteSettings.ts
export const SiteSettings: GlobalConfig = {
  slug: "site-settings",
  fields: [
    { name: "siteTitle", type: "text", required: true },
    { name: "siteDescription", type: "text", required: true },
    {
      name: "socialLinks",
      type: "array",
      fields: [
        { name: "label", type: "text", required: true },
        { name: "icon", type: "upload", relationTo: "media", required: true },
        { name: "url", type: "text", required: true },
      ],
    },
  ],
};

socialLinks.icon is an upload relationship, not a fixed icon-name enum — any modern image format works, including SVG, and an editor can swap GitHub's mark for something else entirely without a code change. The tradeoff shows up at bootstrap time: an upload field can't have a static defaultValue, because there's no Media document to point at until something's actually been uploaded. pnpm seed handles this by uploading two starter icon files and setting the global directly through the Local API rather than leaning on config-level defaults — worth knowing if you hit the same wall with any upload-backed default.

Posts and Pages are almost the same shape — deliberately not quite

Both collections have title, a slug, a description, and a content richtext field. Posts add createdDate / updatedDate and a createdBy relationship (set automatically from the logged-in user, read-only in the admin sidebar). Pages don't need either — nobody's publishing a timeline of homepage revisions.

The slug field itself is worth pulling out as its own thing rather than repeating it:

// src/collections/slugField.ts
export function slugField(): Field {
  return {
    name: "slug",
    type: "text",
    required: true,
    unique: true,
    index: true,
    hooks: {
      beforeValidate: [
        ({ value, data }) => {
          const source = typeof value === "string" && value.trim().length > 0
            ? value
            : data?.title;
          if (typeof source !== "string") return value;
          return source
            .toLowerCase()
            .trim()
            .replace(/['"]/g, "")
            .replace(/[^a-z0-9]+/g, "-")
            .replace(/(^-|-$)/g, "");
        },
      ],
    },
  };
}

Auto-fills from the title if you leave it blank, but a beforeValidate hook rather than a defaultValue means you can still type your own and have it win. Both collections call slugField() instead of hand-writing the same six lines twice — the kind of extraction that only pays off the second time you need it, which is exactly when it happened here.

The one real content-model difference: Pages' content field registers an extra block that Posts' doesn't.

Blocks: an escape hatch for "this doesn't belong in a fixed schema"

A Lexical block is a chunk of structured content an editor can drop directly into a richtext field from the insert-block menu — not a separate field on the document, but something that lives inline in the content stream itself, wherever the editor puts it.

This site has exactly one: SocialLinksList, which renders the same footer social links inline in a page's body.

// src/blocks/SocialLinksList.ts
export const SocialLinksListBlock: Block = {
  slug: "socialLinksList",
  interfaceName: "SocialLinksListBlock",
  labels: { singular: "Social Links List", plural: "Social Links Lists" },
  fields: [],
};

fields: [] looks like an oversight until you notice there's nothing per-instance to configure — it always renders whatever's currently in Site Settings' socialLinks array, so there's nothing to ask the editor for. A block with zero fields is a legitimate design when its entire job is "render this other thing, here, inline."

It's registered on Pages only:

// src/collections/Pages.ts
content: {
  type: "richText",
  editor: lexicalEditor({
    features: ({ defaultFeatures }) => [
      ...defaultFeatures,
      BlocksFeature({ blocks: [SocialLinksListBlock] }),
    ],
  }),
}

Posts' content field uses the plain default editor — no BlocksFeature, so the block can never appear in a post even if someone goes looking for it in the insert menu. That's a call about what the writing is for, not a technical limitation: a social-links block mid-article reads like page furniture, not blog content. If that judgment ever changes, it's a one-line addition to Posts.ts, not a schema migration — which is the actual argument for blocks over, say, ad-hoc HTML pasted into a paragraph.

Reading it back: Local API, not REST

This is the part that actually changed when the two apps became one. The old Astro frontend fetched Payload's REST API over HTTP, at build time, from a separate deployed backend. Now:

// src/lib/payload/client.ts
async function payloadClient() {
  return getPayload({ config });
}

export async function getPostBySlug(slug: string): Promise<Post | null> {
  const payload = await payloadClient();
  const result = await payload.find({
    collection: "posts",
    where: { slug: { equals: slug } },
    limit: 1,
    depth: 1,
  });
  return result.docs[0] ?? null;
}

getPayload({ config }) is an in-process function call that talks to Postgres directly — no base URL, no CORS, no network hop to fail. depth: 1 matters more than it looks: without it, a post's heroImage or a social link's icon comes back as a bare numeric ID, and you'd need a second round-trip to resolve it to an actual Media document with a URL. Depth is Payload's way of saying "populate one level of relationships," and every helper in this file asks for it consistently rather than resolving relationships ad hoc per call site.

mediaURL resolves an upload relationship to an actual URL, optionally requesting a named size (thumbnail / card / hero):

export function mediaURL(
  media: Media | number | null | undefined,
  size?: keyof NonNullable<Media["sizes"]>,
): string | null {
  if (!media || typeof media === "number") return null;
  const chosen = size ? media.sizes?.[size] : undefined;
  return chosen?.url ?? media.url ?? null;
}

The typeof media === "number" check is doing real work — it's the runtime tell for "this relationship wasn't populated," i.e. someone called this without depth: 1 upstream. Worth copying if you're building the same local-API pattern: it turns a silent wrong-URL bug into a clear "you forgot depth" signal instead.

Rendering richtext: two approaches, and why the first one got replaced

Lexical stores richtext as a JSON tree, not HTML — so something has to walk that tree and turn it into markup. This repo has two implementations, and the reason the second one exists is a good demonstration of a specific limitation you'll hit if you go the simple route first.

The first attempt, lexicalToHTML, is a hand-written recursive function that returns a plain string:

// src/lib/payload/richtext.ts
function renderNode(node: LexicalNode): string {
  switch (node.type) {
    case "paragraph": return `<p>${renderChildren(node)}</p>`;
    case "heading": {
      const tag = node.tag && /^h[1-6]$/.test(node.tag) ? node.tag : "h2";
      return `<${tag}>${renderChildren(node)}</${tag}>`;
    }
    // ...link, list, upload, text, etc.
    default:
      return renderChildren(node); // unknown node: render children, don't drop content
  }
}

Clean, dependency-free, easy to unit test — and it works fine right up until you need to render a custom block. A block converter's job here would be "return the HTML for a SocialLinksList," but SocialLinksList is a React component with props and its own rendering logic, not a markup pattern you can express as a template string. A string converter has no way to hand off to a component.

So the render path for both Posts and Pages now goes through Payload's own <RichText> component instead, with a converters function that extends Payload's defaults:

// src/lib/payload/jsxConverters.tsx
export const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({
  ...defaultConverters,
  blocks: {
    socialLinksList: () => <SocialLinksList mode="text" />,
  },
});

This is strictly additive — every node type Payload already knows how to render (paragraphs, headings, lists, links, uploads) keeps working via defaultConverters, and the one line added is the one thing this app needed that Payload couldn't know about in advance: its own custom block.

The old lexicalToHTML is still in the repo, and its test still runs — nothing imports it anymore, but it's a plain string-in-string-out function with no React dependency, so it's the right starting point if this site (or another project reusing the pattern) ever needs to render richtext somewhere a JSX component can't go — an email template, for instance. Worth noting as a real example of code that's dead for the current app but not actually worth deleting yet.

Content goes live immediately, and what that costs elsewhere

Every collection here reads access: { read: () => true } — anyone can read any document, no draft state, no publish step. That's a real simplification, not just a default: it means every afterChange hook has to assume the save it just responded to is now public, so cache invalidation can't wait for a separate "publish" action:

// src/collections/Posts.ts
const revalidatePost: CollectionAfterChangeHook = ({ doc, previousDoc }) => {
  try {
    revalidatePath("/blog");
    revalidatePath("/");
    revalidatePath(`/blog/${doc.slug}`);
    if (previousDoc?.slug && previousDoc.slug !== doc.slug) {
      revalidatePath(`/blog/${previousDoc.slug}`);
    }
  } catch (error) {
    console.error("revalidatePost: failed to revalidate", error);
  }
  return doc;
};

The try/catch isn't defensive boilerplate — revalidatePath only works inside a Next.js request context, and it throws when called from a standalone script like pnpm seed. A revalidation failure is a "the cache is a little stale" problem; a save failure is a "the editor just lost their work" problem. Those aren't the same severity, and the hook is written to reflect that.

There's a real limit to what revalidation buys you, though: it only helps existing content. blog/[slug] uses generateStaticParams() to prebuild every known slug at build time — the same thing Astro's getStaticPaths() did — so a brand-new post's route doesn't exist at all until the next deploy. Editing an existing post goes live instantly; publishing a new one still needs a rebuild. No deploy-hook wiring from Payload to trigger that automatically yet — on the list, not done.

What's still rough

In the spirit of the last post in this series being honest about what deploying didn't fix:

  • Every social-link icon goes through the same media collection as post/page hero images, which means Payload generates thumbnail/card/hero raster variants for a 2KB SVG that only ever gets rendered at its original size. Harmless today, wasted work at any real volume — a dedicated icons collection with no imageSizes is the fix if that ever matters.
  • No drafts, no review step, one admin role. Fine for a single-author blog; the first thing to revisit if this ever needs a second editor.
  • Per-page canonical URLs still aren't set explicitly — same gap the first post in this series flagged, still not closed.

Where that leaves things

That's the content model as it stands today: collections and globals used for what each is actually for, a block that ships without over-building configurability it doesn't need, and a richtext render path that got replaced once, for a concrete reason, rather than over-engineered up front from day one.