Last updated on

Turning on a link Payload already had


I added a feature to this blog that sounds almost too small to write about: when I'm editing a post, I can now link to another post the normal way, by picking it from a list, instead of hand-typing a URL I have to remember to update if the slug ever changes. The dropdown took one line to turn on. Getting it to actually work, and keeping it honest once a linked post gets unpublished, took three files and a debugging detour I didn't see coming.

The dropdown was already built

Payload's Lexical editor ships a LinkFeature with an "Internal Link" option baked in: pick a collection, pick a document, done. I assumed I'd need to build a custom relationship field and a custom toolbar button. I didn't. The whole capability was sitting in @payloadcms/richtext-lexical, just switched off: no collection on this site sets admin.enableRichTextLink, so every editor's link toolbar only ever offered a plain URL field.

Turning it on for Posts took exactly one line:

LinkFeature({
  enabledCollections: ["posts"],
})

Reload the admin, click the link icon, and there's a second radio button: "Internal Link." Pick a post, save. I was ready to call it done.

The picker doesn't know what "published" means

The internal link picker lists every post in the collection, drafts included. An author editing a post can happily link to another author's half-written draft, because the picker has no opinion about _status. Payload does let you override the underlying field's filterOptions, but the moment you set enabledCollections, it silently drops whatever default filter existed and hands you a bare relationship field with none:

LinkFeature({
  enabledCollections: ["posts"],
  fields: ({ defaultFields }) =>
    defaultFields.map((field) =>
      field.type === "relationship" && field.name === "doc"
        ? { ...field, filterOptions: { _status: { equals: "published" } } }
        : field,
    ),
})

Now the picker only offers posts that are actually live, matching the same _status: published rule this collection's own public read access already enforces. An author can still reach a draft by other means, more on that below, but the common path (open the drawer, search, click a title) only ever surfaces something a reader can actually reach.

Payload will happily render a link to nowhere

Here's the part that isn't obvious anywhere in Payload's docs: an internal link doesn't carry a URL. It stores a relation, collection plus document ID, and leaves it to your frontend renderer to turn that into an href. Skip that step, and Payload's default JSX converter renders the link as href="#" and logs an error to the console. Not a build failure. Not a warning in the admin. A dead link that looks completely normal until someone clicks it.

function internalDocToHref({ linkNode }) {
  const doc = linkNode.fields.doc;
  if (!doc || doc.relationTo !== "posts") return "#";

  const { value } = doc;
  const slug = typeof value === "object" && value !== null && typeof value.slug === "string"
    ? value.slug
    : undefined;

  return slug ? `/blog/${slug}` : "#";
}

Wire that into the converter Payload hands your React renderer, and the same link that used to point at # resolves to /blog/you-dont-need-react-until-you-do. I recorded the before-and-after on video for the PR, because a static diff doesn't really sell it: the same click, going nowhere and then going somewhere, argues its own case better than a code comment does.

The bug the picker's filter couldn't catch

I pushed the PR feeling good about the published-only filter. Then a review comment pointed out the gap in it: filterOptions only prunes the list a picker shows you at the moment you're picking. It says nothing about what happens after. Link to a published post today, and if someone unpublishes that post next month, the stored link doesn't change: it still points at the same document ID. My internalDocToHref was still resolving it to a real-looking /blog/<slug> URL. The blog page itself requires _status: published to render at all, so the actual outcome was a 404, not the graceful # fallback the rest of the code was built around.

The fix was the same shape as the picker filter, just re-checked at render time instead of pick time:

const slug =
  typeof value === "object" &&
  value !== null &&
  value._status === "published" &&
  typeof value.slug === "string"
    ? value.slug
    : undefined;

One extra condition. The lesson underneath it is one I keep relearning on this blog: a UI-level restriction and a data-level guarantee are two different claims, worth being precise about which one you have. The picker's filter is a nicety for authors. The _status check at render time is the thing that keeps a reader from hitting a wall.

Why the fix lives in its own file

Both internalDocToHref and the picker's field-filter function started life inline, right where they're used, a few lines each, no reason to split them out. Except a plain function with an if and a ternary is also the easiest kind of thing to unit test, and this repo's CI gates on patch coverage for new code. internalDocToHref lived in jsxConverters.tsx, a file that also imports the React components it renders. Vitest runs tests in a plain Node environment with no bundler resolving @/component aliases, so importing the file at all, just to test one pure function inside it, crashed with a module-resolution error before a single assertion ran.

The fix was to pull the pure logic into its own module with no component imports, the same pattern this codebase already uses for its preview-URL helpers. jsxConverters.tsx imports it back in; the tests import it directly and never touch a React component. Same code, same behavior, now actually testable.

The picker's field-filter function got the same treatment, plus one more thing: it now throws if it ever fails to find the field it's supposed to patch. That predicate, a relationship field named doc, is the only thing tying this code to Payload's internal shape for that field. If a future @payloadcms/richtext-lexical upgrade renames or restructures it, the filter would otherwise just stop matching anything, and drafts would quietly reappear in the picker with nothing to say so. Throwing turns that from a silent regression into a build that won't ship.

One paragraph about the part that wasn't the feature's fault

Verifying all of this meant actually clicking through the admin UI, not just reading a diff, and that took longer than the feature itself for a reason that had nothing to do with Lexical or Payload. I'd started the dev server on a different port than SERVER_URL in .env expects, and every server action in the admin, saving a field, opening a drawer, silently failed auth while the page itself looked perfectly logged in. No error toast. No redirect to login. Just admin forms that quietly stopped updating. Matching the port fixed it instantly. Noting it here mostly so the next time this happens to me, a search turns up this paragraph instead of another hour of guessing.

What actually shipped

One collection-level feature flag, one filter on the document picker, one function to turn a stored relation into a real URL, and a re-check of the same "is this actually published" question at both ends, pick time and render time, because a UI filter and a data guarantee were never the same promise. The whole thing is small enough that the interesting part isn't the feature. It's what it took to trust it.

Comments

No comments yet. Be the first to comment.

Leave a comment