DEV Community

Cover image for Never Ship a Blog Post Without a Featured Image Again
Savage Solutions
Savage Solutions

Posted on • Originally published at savagesolutions.io

Never Ship a Blog Post Without a Featured Image Again

The Bug Nobody Noticed for Weeks

Our blog listing page was silently broken for weeks. Cards rendered with blank image slots, gray rectangles where featured images should have been. No error, no alert, no automated test caught it. A human finally looked at /blog and flagged it.

The root cause was embarrassingly simple: our publishing pipeline allowed posts to go live without a featured image, with the assumption that someone would backfill the image later through an async queue. That assumption was wrong. Posts shipped to production, listing cards went blank, and the queue sat unprocessed.

This is the story of how we fixed it with a hard publish gate, and why the fix is permanent.

How the Pipeline Worked Before

We run a content pipeline that publishes through two paths: Payload CMS and a gitMdx flow for markdown-based posts. Both paths fed into the same listing page, and both had the same gap: neither required a featured image URL before allowing a post to move from draft to published.

The async image queue was designed as a convenience. If a post was ready to go but the image was still being generated (we use HeyGen for some video thumbnails and other tooling for static images), the post could publish and the image would follow. In practice, the image rarely followed on time. The queue backed up, editors forgot, and the listing page accumulated blank cards.

We also had a secondary problem: internal tooling and some post metadata were using the short form Savage Digital Solutions instead of the full name Savage Digital Solutions. That inconsistency was slipping into published content.

The Fix: evaluatePublishGate

We wrote a single gate function called evaluatePublishGate that runs before either publish path can execute. The rule is binary: if the post does not have a valid https featured image URL, it does not ship. It stays in draft.

Here is the core logic:

function evaluatePublishGate(post: PostPayload): GateResult {
  const { featuredImage, status, brandMentions } = post;

  if (!featuredImage || !featuredImage.startsWith('https://')) {
    return {
      approved: false,
      reason: 'Missing or invalid featured image URL. Post forced to draft.',
    };
  }

  const forbiddenBrandForm = /\bSavage Solutions\b/i;
  if (brandMentions?.some((m) => forbiddenBrandForm.test(m))) {
    return {
      approved: false,
      reason: 'Short brand form detected. Use full name: Savage Digital Solutions.',
    };
  }

  return { approved: true };
}
Enter fullscreen mode Exit fullscreen mode

This runs synchronously before the Payload publish hook and before the gitMdx pipeline writes to the production branch. If approved is false, the post status is set to draft and the publish is aborted. No exceptions, no overrides.

The brand name check was added at the same time. The gate now rejects any post where the string Savage Digital Solutions appears without the full Savage Digital Solutions form. This catches copy-paste errors and shorthand that crept in from internal tooling.

What Changed in the Payload Hook

In Payload CMS, collection hooks run at specific lifecycle points. We attached evaluatePublishGate to the beforeChange hook on the Posts collection:

beforeChange: [
  ({ data, operation }) => {
    if (operation === 'update' && data.status === 'published') {
      const result = evaluatePublishGate(data);
      if (!result.approved) {
        data.status = 'draft';
        console.warn(`[PublishGate] Blocked: ${result.reason}`);
      }
    }
    return data;
  },
],
Enter fullscreen mode Exit fullscreen mode

The hook intercepts any attempt to set status: 'published', runs the gate, and silently downgrades to draft if the gate fails. The console.warn feeds into our logging pipeline so we can track how often posts are blocked and why.

For the gitMdx path, we added the same check as a pre-commit validation step. The script reads the frontmatter of any markdown file staged for the production branch and calls evaluatePublishGate before allowing the commit to proceed.

The Async Queue Still Exists, But It Cannot Bypass the Gate

We did not remove the async image queue. It still exists for repair work: if a published post needs its image updated or replaced, the queue handles that. But the queue no longer serves as a workaround for missing images on new posts.

The distinction matters. Repair and creation are different operations. A post that already has a valid featured image can have that image updated asynchronously without breaking the listing card. A post that has no image at all will render a blank card the moment it goes live. The gate only blocks the second case.

This also means the queue backlog is now smaller and more predictable. It handles genuine updates, not a pile of posts waiting for images that should have been required upfront.

The Rule We Wrote Down

After shipping the fix, we wrote one sentence in our internal docs:

If the card would look broken on /blog, it does not ship.

That sentence covers the featured image requirement, the brand name check, and any future gate conditions we add. It is a product standard, not a technical constraint. The technical constraint (the gate function) exists to enforce the product standard.

This framing helped when we discussed adding more gate conditions later. Every proposed condition gets evaluated against the same question: would a missing or malformed version of this field cause a visible defect on the listing page or in a post? If yes, it belongs in the gate.

What We Would Do Differently

The async backfill pattern was a mistake from the start. It optimized for publishing speed at the cost of listing page integrity. The right default is always to require complete data before publishing, not to publish incomplete data and fix it later.

If you are running a Next.js blog with Payload or any headless CMS, the place to enforce this is in the CMS hook layer, not in the frontend. The frontend should be able to assume that any published post has a valid featured image. Defensive rendering in the listing component (fallback images, skeleton states) is fine for edge cases, but it should not be the primary defense against missing data.

The team at Savage Digital Solutions (savagesolutions.io) now treats evaluatePublishGate as a template for other content quality checks. The pattern is reusable: define the condition that would cause a visible defect, write a synchronous check, attach it to the publish lifecycle, and force draft on failure.

Key Takeaways

  • A missing featured image causes blank listing cards in production. This is a silent failure with no automatic alerts unless you have explicit gate logic.
  • evaluatePublishGate runs synchronously before both Payload CMS publish hooks and gitMdx pipeline commits. It requires a valid https featured image URL or forces the post to draft.
  • The same gate enforces full brand name usage. The short form Savage Digital Solutions is blocked at the gate level, not caught in post-publish review.
  • Async image queues are valid for repair operations on already-published posts. They are not a substitute for requiring complete data before initial publish.
  • Attach quality gates to the CMS beforeChange hook, not to frontend rendering logic. The frontend should receive clean data, not compensate for missing data.
  • One sentence captures the standard: if the card would look broken on /blog, it does not ship.

Top comments (0)