Handling References to Unpublished Entries in Published Pages

Part of Draft State Management, this guide covers a failure that appears only in production: a published page references an entry that is still a draft, archived or deleted, and the delivery API returns the page with that reference missing, so a component that expects an object receives null, an empty array or a bare link stub.

Every headless CMS allows references to point at entries in any state, because editors need to build pages before every linked piece is ready. Preview hides the problem, since preview APIs resolve drafts. The published page only breaks after publishing, often on a busy launch day when a campaign page goes live before its hero banner or its featured product entry.

The same page in preview and in productionA landing page references a hero banner that is still a draft; the preview API resolves the banner, while the delivery API drops it, so the published page receives a null hero.Landing pagepublishedHero bannerdraftPreview APIhero resolvedDelivery APIhero missingPreview renderscorrectlyPublished pageTypeErrorreference
Preview and delivery disagree about the same reference, which is why the bug never shows up during review.

The Problem

A retail team builds a spring campaign page in Contentful. The page entry references a hero banner, three product teasers and a promotional code block. The page is approved and scheduled for 08:00. The hero banner, owned by the design team, is still in draft at 08:00 because its final image is pending. When the page publishes, Contentful’s delivery API returns the page with the hero link unresolved: in the REST API, the link remains in fields.hero but no matching entry appears in includes; in the GraphQL API, hero is null. The React component destructures hero.image.url, throws, and the page renders the framework’s error screen for the first hours of the campaign.

The same mechanism causes quieter bugs: a listing that skips archived entries but shows a gap in the grid, a related-articles block that renders three cards instead of four, and author bylines that disappear when an author profile is unpublished for editing.

How Each CMS Represents a Missing Reference

Platforms differ in how they signal the problem, which is why a single defensive pattern in the fetch layer is worth writing once.

What the delivery API returns for an unresolvable referenceHow Contentful, Sanity, Strapi, Storyblok and Hygraph represent a reference to an entry that is not published, in their public delivery APIs.CMSSingle referenceReference listDetectable?Contentful RESTlink stub, no includestubs without includescompare with includesContentful GraphQLnullnulls filtered or keptnull, check errors arraySanity (published perspective)_ref to missing doc; deref returns nullnulls in arraynull after derefStrapi v5nullshorter arraysilently absentStoryblokuuid string, unresolveduuids not resolvedstring instead of objectHygraphnullshorter arraysilently absent
Some APIs drop the value, some keep a stub; the frontend must handle both shapes.

The shapes fall into two groups. Some APIs keep a stub, such as a link object or a uuid string, that shows where content should have been. Others drop the value or return null. A robust fetch layer normalizes both into one explicit representation, so components can decide what to render without knowing which CMS is behind them.

Implementation

The approach has three parts: model the possibility in types, normalize API responses into those types, and make every component render something sensible for the missing case.

TypeScript
// lib/refs.ts
export type Ref<T> = { status: "resolved"; value: T } | { status: "missing"; id: string | null };

// Contentful REST: resolve links against the includes collection.
interface Link {
  sys: { type: "Link"; linkType: "Entry" | "Asset"; id: string };
}

interface Includes {
  Entry?: Array<{ sys: { id: string }; fields: Record<string, unknown> }>;
}

export function resolveEntryLink<T>(link: Link | undefined, includes: Includes, map: (fields: Record<string, unknown>) => T): Ref<T> {
  if (!link) return { status: "missing", id: null };
  const entry = includes.Entry?.find((e) => e.sys.id === link.sys.id);
  return entry ? { status: "resolved", value: map(entry.fields) } : { status: "missing", id: link.sys.id };
}

export function resolveList<T>(links: Link[] | undefined, includes: Includes, map: (fields: Record<string, unknown>) => T): { items: T[]; missing: string[] } {
  const items: T[] = [];
  const missing: string[] = [];
  for (const link of links ?? []) {
    const r = resolveEntryLink(link, includes, map);
    if (r.status === "resolved") items.push(r.value);
    else if (r.id) missing.push(r.id);
  }
  return { items, missing };
}

// components/Hero.tsx
import type { Ref } from "@/lib/refs";

interface HeroData {
  heading: string;
  imageUrl: string;
  imageAlt: string;
}

export function Hero({ hero, fallbackHeading }: { hero: Ref<HeroData>; fallbackHeading: string }): JSX.Element {
  if (hero.status === "missing") {
    // Render a neutral header rather than crashing or leaving a hole.
    return (
      <header className="hero hero--plain">
        <h1>{fallbackHeading}</h1>
      </header>
    );
  }
  const { heading, imageUrl, imageAlt } = hero.value;
  return (
    <header className="hero">
      <img src={imageUrl} alt={imageAlt} width={1600} height={900} />
      <h1>{heading}</h1>
    </header>
  );
}

The Ref<T> type forces every consumer to handle the missing case at compile time, which is the main benefit: the next developer who adds a component cannot forget. Lists return both the resolved items and the missing ids, so a grid can render the items it has and log the gaps.

Logging the gaps is the second half. Report every missing reference on published pages to your monitoring with the parent entry id, the field and the missing id. The content team then gets a list of pages that published with incomplete references, which they can fix by publishing the referenced entries.

Configuration Reference

Setting Where Purpose
Required reference fields CMS content model Blocks publishing of the parent when the field is empty, not when the target is unpublished.
Reference validation on publish CMS workflow app or plugin Prevents publishing a parent whose references are drafts.
include depth Contentful REST Must cover the referenced depth, or resolvable links look missing.
resolve_relations Storyblok Lists which relation fields to resolve into objects.
populate Strapi Controls which relations are returned at all.
Missing-reference metric monitoring Counts gaps per page type after each publish.

Note the difference between the first two rows. A required field in the CMS only checks that a reference exists, not that its target is published. Blocking publication of a parent whose references are still drafts needs a workflow rule, such as Contentful’s reference validation in a custom app or a Sanity document action that checks referenced documents before publishing.

Gotchas & Edge Cases

  • Include depth mistaken for unpublished content. Contentful resolves includes to a maximum depth. A reference deeper than the requested include value looks exactly like a missing one. Set the depth to what the page renders and treat misses at the deepest level with suspicion.
  • Assets are references too. An image asset that is unpublished or deleted leaves the entry with a dangling asset link. Handle assets with the same Ref pattern and render a placeholder with explicit dimensions to avoid layout shift.
  • Caching the broken page. An ISR page rendered with a missing reference stays broken in the cache after the reference is published, because publishing the child does not invalidate the parent. Tag parent fetches with every referenced id, including missing ones, so a publish of the child revalidates the parent.
  • Localized references. A reference can be resolved in one locale and missing in another when the target entry has no translation and no fallback. Run missing-reference checks per locale.
  • Preview hides everything. Because preview resolves drafts, add a preview banner warning that lists referenced entries that are not yet published, so editors see the risk before they publish.

Verifying the Result

Create a test page in a staging environment that references one published and one draft entry of each referenced type, publish only the page, and render it through the delivery API. Every component should render its fallback for the draft reference, no error should reach the error boundary, and the monitoring should record one missing reference per draft target. Then publish the draft targets and confirm the parent page regenerates without a manual purge, which proves the dependency tags work.

Publishing a child revalidates the parentThe parent page was cached with a missing hero; its fetch was tagged with the hero's id, so publishing the hero triggers a webhook that invalidates that tag and regenerates the parent with the hero resolved.EditorCMSWebhook routeParent page cachecached with hero missingtags: page:1, hero:9publish hero 9entry.publish hero:9revalidateTag(hero:9)next request regenerateshero resolved
Tagging with missing ids is what lets a late publish repair the page automatically.

Rollout Checklist

  • Introduce the Ref<T> type and a resolver per CMS in the fetch layer.
  • Update components that consume references to render fallbacks for the missing case.
  • Tag parent fetches with the ids of all referenced entries, resolved or not.
  • Report missing references on published pages to monitoring and to the content team.
  • Add a pre-publish check in the CMS that warns about draft references.
  • Show unpublished references in the preview banner.

Frequently Asked Questions

Should a page with a missing required reference return 404 instead?

Rarely. Most pages are still useful without one referenced block, and a 404 on a campaign page is worse than a plain header. Reserve 404s for references that define the page, such as the product entry behind a product detail page.

Can the CMS prevent this entirely?

Workflow rules can block publishing a parent while references are drafts, and some platforms support publishing a parent together with its references as a release. Both help, but references can still break later when a child is unpublished or deleted, so the frontend must handle missing references regardless.

How do I find existing pages with broken references?

Crawl the published site, or better, query the delivery API for every page entry and run the same resolver used in rendering, collecting missing ids. A nightly job that produces this report keeps the content team informed without waiting for a reader to notice.

Should editors be warned before publishing a page with draft references?

Yes, and it is the cheapest fix of all. A CMS sidebar app or document action that lists unpublished references at publish time catches most cases before they reach readers, and it teaches editors which entries their pages depend on.

Does GraphQL make this easier?

It makes the missing case explicit, since the field is null and schema types can mark it nullable, which generated TypeScript types then enforce. The rendering and caching problems are the same as with REST.