The SEO Plugin That Ate My Admin Sidebar


Filed under Operations

I turned on the official SEO plugin for this blog's CMS, and my Posts editor turned into a single scrolling column. Slug, category, tags, series: all the sidebar fields that used to sit neatly on the right were suddenly rendered inline, underneath the title, in one long stack. Nothing else had changed, and I hadn't touched the layout. It just broke.

The setup

The plugin is @payloadcms/plugin-seo, the official SEO add-on for Payload CMS. It adds a meta field group (title, description, an OG image) to whichever collections you point it at, plus a live search/social preview and "Auto-generate" buttons that default the SEO copy from your existing content. I wired it up with one extra option: tabbedUI: true, which is supposed to put that whole SEO group behind a separate "SEO" tab instead of dumping it at the bottom of an already-long field list.

That part worked. What I didn't expect was for the rest of the edit screen to lose its sidebar.

Finding the actual cause

The library isn't a black box. The installed package is plain JavaScript in node_modules, so instead of guessing, I opened it and read it.

The plugin's tabbedUI code path does something specific: it takes every field already on your collection and copies it into a generated "Content" tab, building a two-tab shell (one for what already existed, one for the new SEO group). It even special-cases one edge: if your collection has an auth email field, that field gets pulled out and kept separate. But that's the only field it treats specially. Everything else, including any field marked to render in the sidebar, gets swept into the tab along with the rest.

Then I read the other half: the component in Payload's own admin UI that decides whether a document gets a two-column layout at all. Its logic is a plain reduce over the fields it's handed. Sidebar fields go one way, everything else goes the other, and it only ever looks at the top-level array. It has no idea a tab even exists, and it never looks inside one.

Put those two facts together and the bug explains itself: a sidebar field nested inside a tab is invisible to the code that's supposed to notice it. No top-level sidebar fields means no sidebar, even though the field is still there, still saving data. It's just rendered in the wrong place.

Proving it in isolation

Before reporting anything, I wanted a reproduction that had nothing to do with my own site, in case the bug was actually some interaction with my own config. So I scaffolded a brand-new, empty Payload project from the official template, added a two-field collection (a title and one field marked for the sidebar), turned on the same plugin option, and reproduced it in under two minutes. Toggling tabbedUI back to false fixed it instantly there too. That ruled out anything specific to my setup: the bug lives in the plugin itself. I pushed that minimal reproduction as its own repo so anyone else hitting this could confirm it in under a minute too.

Filing it, and the answer I got back

I opened an issue with the minimal reproduction attached, the exact lines responsible, and a suggested fix. Someone from the community showed up with almost the identical fix in mind before a maintainer weighed in.

The maintainer's answer, paraphrased: this is intended behavior, a known limitation of combining tabs with sidebar fields, and there's a documented way around it: build the SEO fields yourself, directly, instead of letting the plugin auto-inject them.

That's a fair answer, even if it wasn't the one I was hoping for. The plugin exports its individual field builders (the title field, the description field, the image field, the live preview) specifically so you can compose them into your own field layout instead of accepting the plugin's automatic one. I just hadn't needed that escape hatch until now.

What the fix actually looked like

The plugin wasn't doing much magic underneath its tabbedUI option. It was just building an object out of a handful of exported field factories and shoving it into your config, so I built that same object myself:


import { seoPlugin } from '@payloadcms/plugin-seo'

seoPlugin({
  collections: ['posts', 'pages'],
  tabbedUI: true,
  generateTitle,
  generateDescription,
  generateURL,
})

import { seoPlugin } from '@payloadcms/plugin-seo'
import {
  MetaDescriptionField,
  MetaImageField,
  MetaTitleField,
  OverviewField,
  PreviewField,
} from '@payloadcms/plugin-seo/fields'

// Keep the plugin around only for its generate-* endpoints.
// No collections, so it never auto-injects anything.
seoPlugin({ generateTitle, generateDescription, generateURL })

function seoMetaField() {
  return {
    name: 'meta',
    type: 'group',
    label: 'SEO',
    fields: [
      OverviewField({}),
      MetaTitleField({ hasGenerateFn: true }),
      MetaDescriptionField({ hasGenerateFn: true }),
      MetaImageField({ hasGenerateFn: false, relationTo: 'media' }),
      PreviewField({ hasGenerateFn: true }),
    ],
  }
}

Then, in the collection itself, I built the tabs by hand instead of letting the plugin do it, which meant I got to decide exactly which fields go inside the tab and which stay outside it:

fields: [
  {
    type: 'tabs',
    tabs: [
      { label: 'Content', fields: [/* title, body, etc. */] },
      { label: 'SEO', fields: [seoMetaField()] },
    ],
  },
  // Every sidebar field stays OUT here, as a sibling of the tabs field,
  // not nested inside it.
  slugField(),
  categoryField(),
  tagsField(),
]

Same tabbed SEO section, same auto-generate buttons, same live preview. The only difference is that Payload's sidebar detection can actually see the sidebar fields now, because they're sitting where it looks.

What it looked like before and after

Same post, same browser width, nothing else touched:

Before: single column, the slug field buried inline under the title, no sidebar at all.

Payload admin edit view before the fix: single column, Slug field buried inline under the title, no sidebar

After: two columns again, slug, category, tags, series, and created-by all back in the sidebar, with the SEO tab still fully functional.

Payload admin edit view after the fix: two-column layout restored, Slug/Category/Tags/Series/Created By back in the sidebar

I also clicked through the "Auto-generate" buttons on both sides to make sure I hadn't quietly broken the thing I'd just spent an hour investigating. They still work: the plugin's generate endpoints don't care which collection triggered them, so dropping the automatic field injection didn't touch that part at all.

The part I almost skipped

Once the fix was in and the layout was visibly correct again, an automated review pass on the change flagged something I'd genuinely missed: the rule I was now depending on (sidebar fields must never end up nested inside the tabs field) existed only as a comment. Nothing would catch it if a future edit quietly violated it.

So I wrote a small test that walks a collection's entire field tree and fails if a sidebar-positioned field turns up anywhere other than the top level. Before trusting it, I did the obvious thing: deliberately broke the rule by moving one sidebar field back inside the tab, reran the test, watched it fail for the right reason, and then put the field back. A test that's never seen the bug it claims to catch isn't proof of anything. It's just a plausible-looking assertion.

What I'd take from this

A plugin option can be "working as intended" and still be a trap. tabbedUI does exactly what its own docs describe, and the failure mode only shows up when it collides with a completely separate feature (sidebar positioning) that neither the plugin's author nor its docs mention in the same breath. The bug wasn't in either piece of code. It was in the gap between them.

Reading the actual library source beat guessing. I could have spent a long time experimenting with config permutations, but ten minutes in node_modules gave me the exact two lines of logic that explained the whole thing, one in the plugin and one in the framework it plugs into.

A reproduction that has nothing to do with your own app is worth building even when you're fairly sure of the cause. It took less time than writing the issue itself, and it turned "I think this is a bug" into something a maintainer could act on without needing to trust my judgment.

And a documented invariant is not an enforced one. The comment describing the constraint was accurate the whole time. The code just didn't require anyone to honor it. The version of "done" that includes a test proving the rule actually holds is a different, better version of done than the one that just describes the rule and hopes.

Comments

No comments yet. Be the first to comment.

Leave a comment