BuildWithMatija
  1. Home
  2. Blog
  3. Payload
  4. Payload CMS Archival System: Schedule Content Retirement

Payload CMS Archival System: Schedule Content Retirement

Payload CMS v3: step-by-step soft-archival guide with sidebar UI, admin filters, scheduled job, and search exclusion.

27th August 2026·Updated on:29th August 2026··
Payload
Payload CMS Archival System: Schedule Content Retirement

Evaluating Payload CMS Implementation Costs?

Scope design, content structure, and migration hours to estimate a realistic production timeline and hosting setup.

Try the Cost EstimatorGet a Second Opinion

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Contents

  • Why Soft Retirement Instead of Deletion
  • Architecture Overview
  • Step 1: Database Schema and Field Definitions
  • Step 2: Server-Side Validation Hooks
  • Step 3: Interactive Sidebar UI Component
  • Step 4: List View Integration
  • The Query-Aware baseFilter
  • The Filter Bar Component
  • Registering It on a Collection
  • Step 5: Background Scheduled Archival Task
  • Step 6: Frontend and Search Isolation
  • A Single Shared Query Boundary
  • Excluding Archived Content from Search
  • FAQ
  • Wrapping Up
On this page:
  • Why Soft Retirement Instead of Deletion
  • Architecture Overview
  • Step 1: Database Schema and Field Definitions
  • Step 2: Server-Side Validation Hooks
  • Step 3: Interactive Sidebar UI Component
Build with Matija logo

Build with Matija

Senior-led B2B websites, applications, content systems, and digital infrastructure. Business-first, full-stack, AI-assisted, no handoffs.

Services

  • B2B Website Development
  • CMS Architecture Review & Platform Blueprint
  • Next.js + Payload Advisory
  • AI Integration & Implementation

Resources

  • CMS Hub
  • B2B Website Strategy
  • E-commerce Hub
  • Blog
  • Case Studies

Payload CMS

  • Payload CMS Developer
  • Payload CMS Migration
  • Payload CMS Demos
  • All Payload CMS Resources

Discuss your project

Planning a rebuild, migration, application, workflow change, or platform decision? Start with the business problem and the system behind it.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

Hard-deleting content in Payload CMS breaks foreign-key relationships, wipes out audit history, and hands search engines a fresh batch of 404s the moment an editor clicks "delete." This guide walks through a production archival system that lets editors retire content instantly or schedule a future retirement date, keeps every relational reference intact, hides retired items from list views and search indexes, and restores them with one click. It uses @payloadcms/ui components (ConfirmationModal, Pill, useField, useFormFields), a beforeValidate hook for data integrity, admin.baseFilter for query-aware list views, and Payload's Jobs Queue for scheduled transitions. Every piece below is verified against a real collection setup, with pnpm typecheck and pnpm lint passing clean and the full test suite (694 tests) green.

A recent client site had a familiar problem: seasonal promotions, limited-time giveaways, and expired job postings needed to come off the live site without disappearing from the CMS. Editors wanted to retire something today or line up a retirement date in advance, and they wanted the option to bring it back if plans changed. The blog collection, category taxonomy, and recipe cross-references all pointed at these documents, so deleting them outright would have left orphaned relationship fields and broken internal links across the site. What follows is the system built to solve that, layer by layer, from the database schema up through the public-facing query boundary.

This builds on patterns from my earlier guide to custom admin fields and views with @payloadcms/ui. If useField, FieldLabel, or the flat @payloadcms/ui import pattern are unfamiliar, that guide covers the foundation this one builds on.

Why Soft Retirement Instead of Deletion

Before touching code, it's worth being explicit about what each approach actually costs you in a relational content system.

ApproachWhat happensTrade-off
Hard deleteDocument row removed from the databaseBreaks relationship fields pointing to it, produces 404s for indexed URLs, destroys audit history permanently
Payload draft/unpublish_status flips to draft, document stays queryable to authenticated usersGood for pre-publish work; not designed to model a "this ran its course" state or a future retirement date
Soft archival (this guide)archived flag and archivedAt timestamp control visibilityRequires extra schema fields and query filtering everywhere content is fetched, but preserves relations, history, and lets content come back

Soft archival adds a small amount of schema and query overhead in exchange for content that can be retired, scheduled, restored, and audited without ever leaving referential integrity behind. The rest of this guide implements that trade-off end to end.

Architecture Overview

The system spans four layers: the database schema, the admin editing experience, a background scheduler, and the public query boundary.

Diagram

An editor sets the archival state from the document sidebar. A validation hook keeps archived and archivedAt from ever disagreeing with each other. A background job checks for documents whose scheduled date has arrived and flips them over automatically. Every query the public site runs filters through a single shared helper, so there's exactly one place that decides whether archived content leaks onto a live page.

Step 1: Database Schema and Field Definitions

Three fields carry the whole feature. Two of them are plain database columns, hidden from the admin UI, and the third is a presentational field that renders the interactive sidebar control:

typescript
// File: src/payload/fields/archive.ts
import type { Field } from "payload";

/** Database boolean flag (hidden in Admin UI) */
export const archivedField: Field = {
  name: "archived",
  type: "checkbox",
  defaultValue: false,
  index: true,
  admin: {
    hidden: true,
    description: "Whether this document is archived and hidden from live public queries.",
  },
};

/** Database ISO date-time string (hidden in Admin UI) */
export const archivedAtField: Field = {
  name: "archivedAt",
  type: "date",
  index: true,
  admin: {
    hidden: true,
    description: "Effective timestamp of archival or future scheduled retirement.",
  },
};

/** Interactive Presentational UI Field rendered in the sidebar */
export const archiveStatusUIField: Field = {
  name: "archiveStatusUI",
  type: "ui",
  admin: {
    position: "sidebar",
    components: {
      Field: "/src/payload/admin-components/archive/ArchiveSidebarField#ArchiveSidebarField",
    },
  },
};

Splitting the data fields from the presentation field is what keeps the sidebar clean. Payload's default checkbox and date-picker rendering for archived and archivedAt would show up as two separate, disconnected controls with no shared state or confirmation flow. Marking them admin.hidden: true removes them from the form entirely while keeping them fully queryable and indexed in the database. The archiveStatusUIField is a type: "ui" field with no database column of its own; it exists purely to mount a custom React component that reads and writes the two hidden fields through Payload's form state.

Step 2: Server-Side Validation Hooks

Client-side state can drift, browser tabs can be left open, and API requests can come from anywhere. A beforeValidate hook enforces the one invariant this feature depends on: a document can never be marked archived: true while also carrying a future archivedAt date.

typescript
// File: src/payload/features/archive/validation.ts
import type { CollectionBeforeValidateHook } from "payload";

export const validateArchiveFieldsBeforeValidate: CollectionBeforeValidateHook = ({
  data,
  operation,
}) => {
  if (!data) return data;

  const archived = Boolean(data.archived);
  const archivedAt = data.archivedAt ? String(data.archivedAt) : null;

  if (archived) {
    if (archivedAt) {
      const targetTime = new Date(archivedAt).getTime();
      if (!Number.isNaN(targetTime) && targetTime > Date.now()) {
        throw new Error(
          "Cannot set 'archived' to true with a future date. To schedule future retirement, keep 'archived' as false and set 'archivedAt'.",
        );
      }
    } else {
      // Auto-populate timestamp if archived immediately without specifying a date
      data.archivedAt = new Date().toISOString();
    }
  }

  return data;
};

Scheduled retirement is represented by archived: false paired with a future archivedAt, and immediate retirement is represented by archived: true paired with a past or current archivedAt. Any request that tries to combine archived: true with a future date gets rejected before it reaches the database. This runs server-side regardless of whether the request came from the admin UI, the REST API, or a script, so the invariant holds even if the sidebar component is bypassed entirely.

Step 3: Interactive Sidebar UI Component

This is where the components from the @payloadcms/ui guide come together into a working feature. The sidebar field walks editors through three states, each with its own pill and action buttons: active content shows a green pill and an "Archive content…" button, scheduled content shows an amber pill with the target date plus "Remove Archival" and "Reschedule…" buttons, and archived content shows a red pill with a "Restore / Unarchive" button.

tsx
// File: src/payload/admin-components/archive/ArchiveSidebarField.tsx
"use client";

import React, { useMemo, useState, useSyncExternalStore } from "react";
import {
  Button,
  ConfirmationModal,
  FieldDescription,
  FieldLabel,
  Pill,
  toast,
  useDocumentInfo,
  useField,
  useFormFields,
  useModal,
} from "@payloadcms/ui";

function subscribe() {
  return () => {};
}

function getSnapshot() {
  return true;
}

function getServerSnapshot() {
  return false;
}

function toLocalDatetimeString(date: Date): string {
  const pad = (n: number) => String(n).padStart(2, "0");
  const yyyy = date.getFullYear();
  const MM = pad(date.getMonth() + 1);
  const dd = pad(date.getDate());
  const hh = pad(date.getHours());
  const mm = pad(date.getMinutes());
  return `${yyyy}-${MM}-${dd}T${hh}:${mm}`;
}

function formatDate(isoString?: string | null): string {
  if (!isoString) return "";
  try {
    return new Date(isoString).toISOString().slice(0, 10);
  } catch {
    return String(isoString).slice(0, 10);
  }
}

export function ArchiveSidebarField() {
  const { id: documentId } = useDocumentInfo();
  const { openModal } = useModal();

  // Hydration-safe client mounting state
  const isMounted = useSyncExternalStore(
    subscribe,
    getSnapshot,
    getServerSnapshot,
  );

  const { value: archivedVal, setValue: setArchived } = useField<boolean>({
    path: "archived",
  });
  const { value: archivedAtVal, setValue: setArchivedAt } = useField<string | null>({
    path: "archivedAt",
  });

  const formArchived = useFormFields(([fields]) => fields.archived?.value);
  const formArchivedAt = useFormFields(([fields]) => fields.archivedAt?.value);

  const archived = Boolean(archivedVal ?? formArchived);
  const archivedAt = (archivedAtVal ?? formArchivedAt) as string | undefined | null;

  const [dateInput, setDateInput] = useState<string>("");
  const [isSelectedDateFuture, setIsSelectedDateFuture] = useState<boolean>(false);

  const archiveModalSlug = useMemo(
    () => `archive-modal-${String(documentId ?? "draft")}`,
    [documentId],
  );
  const restoreModalSlug = useMemo(
    () => `restore-modal-${String(documentId ?? "draft")}`,
    [documentId],
  );
  const cancelModalSlug = useMemo(
    () => `cancel-archival-modal-${String(documentId ?? "draft")}`,
    [documentId],
  );

  const isScheduled = Boolean(!archived && archivedAt);

  const handleOpenArchiveModal = () => {
    let initialDate = new Date();
    if (archivedAt) {
      const existingDate = new Date(archivedAt);
      if (!Number.isNaN(existingDate.getTime())) {
        initialDate = existingDate;
      }
    }
    setDateInput(toLocalDatetimeString(initialDate));
    setIsSelectedDateFuture(initialDate.getTime() > Date.now());
    openModal(archiveModalSlug);
  };

  const handleDateChange = (val: string) => {
    setDateInput(val);
    if (!val) {
      setIsSelectedDateFuture(false);
      return;
    }
    const time = new Date(val).getTime();
    setIsSelectedDateFuture(!Number.isNaN(time) && time > Date.now());
  };

  const handleConfirmArchive = () => {
    const chosenDate = dateInput ? new Date(dateInput) : new Date();
    const isFuture = chosenDate.getTime() > Date.now();

    if (isFuture) {
      setArchived(false);
      setArchivedAt(chosenDate.toISOString());
      toast.success(
        `Archival scheduled for ${formatDate(chosenDate.toISOString())}. Save document to apply.`,
      );
    } else {
      setArchived(true);
      setArchivedAt(chosenDate.toISOString());
      toast.success("Content marked as archived (retired). Save document to apply.");
    }
  };

  const handleConfirmRestore = () => {
    setArchived(false);
    setArchivedAt(null);
    toast.success("Content restored to active. Save document to apply.");
  };

  const handleCancelPendingArchival = () => {
    setArchived(false);
    setArchivedAt(null);
    toast.success("Scheduled archival removed. Save document to apply.");
  };

  return (
    <div className="field-type" style={{ marginBottom: "1.25rem" }}>
      <div
        style={{
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
          marginBottom: "0.5rem",
        }}
      >
        <FieldLabel label="Lifecycle Status" />
        {archived ? (
          <Pill pillStyle="error" size="small">
            Archived (Retired)
          </Pill>
        ) : isScheduled ? (
          <Pill pillStyle="warning" size="small">
            Scheduled: {formatDate(archivedAt)}
          </Pill>
        ) : (
          <Pill pillStyle="success" size="small">
            Active
          </Pill>
        )}
      </div>

      <div
        style={{
          display: "flex",
          gap: "0.5rem",
          alignItems: "center",
          flexWrap: "wrap",
          marginTop: "0.35rem",
          marginBottom: "0.35rem",
        }}
      >
        {archived ? (
          <Button
            buttonStyle="secondary"
            margin={false}
            onClick={() => isMounted && openModal(restoreModalSlug)}
            size="small"
          >
            Restore / Unarchive
          </Button>
        ) : isScheduled ? (
          <>
            <Button
              buttonStyle="secondary"
              margin={false}
              onClick={() => isMounted && openModal(cancelModalSlug)}
              size="small"
            >
              Remove Archival
            </Button>
            <Button
              buttonStyle="subtle"
              margin={false}
              onClick={handleOpenArchiveModal}
              size="small"
            >
              Reschedule…
            </Button>
          </>
        ) : (
          <Button
            buttonStyle="secondary"
            margin={false}
            onClick={handleOpenArchiveModal}
            size="small"
          >
            Archive content…
          </Button>
        )}
      </div>

      <FieldDescription
        description="Retires previously published content from the live public site without deleting it."
        path="archived"
      />

      {isMounted ? (
        <>
          {/* Modal: Schedule / Immediate Archival */}
          <ConfirmationModal
            body={
              <div style={{ display: "flex", flexDirection: "column", gap: "1rem", marginTop: "0.5rem" }}>
                <p style={{ margin: 0, color: "var(--theme-elevation-700)", lineHeight: 1.5 }}>
                  Archiving retires previously published content from the live public site without deleting it from the CMS.
                </p>
                <div style={{ display: "flex", flexDirection: "column", gap: "0.35rem" }}>
                  <FieldLabel htmlFor="archive-date-input" label="Effective Archival Date & Time" />
                  <input
                    id="archive-date-input"
                    onChange={(e) => handleDateChange(e.target.value)}
                    style={{
                      padding: "0.5rem 0.75rem",
                      borderRadius: "var(--style-radius-s, 4px)",
                      border: "1px solid var(--theme-elevation-250)",
                      backgroundColor: "var(--theme-elevation-0)",
                      color: "var(--theme-elevation-900)",
                      fontSize: "0.9rem",
                      fontFamily: "inherit",
                    }}
                    type="datetime-local"
                    value={dateInput}
                  />
                  <FieldDescription
                    description={
                      isSelectedDateFuture
                        ? "🗓️ Setting a future date will keep this document active until that date, then retire it automatically."
                        : "⚠️ Setting the current or past date will retire this document immediately upon saving."
                    }
                    path="archivedAt"
                  />
                </div>
              </div>
            }
            confirmLabel={isSelectedDateFuture ? "Schedule Archival" : "Archive Content"}
            confirmingLabel={isSelectedDateFuture ? "Scheduling…" : "Archiving…"}
            heading="Archive this content?"
            modalSlug={archiveModalSlug}
            onConfirm={handleConfirmArchive}
          />

          {/* Modal: Restore to Active */}
          <ConfirmationModal
            body={
              <p style={{ margin: 0, color: "var(--theme-elevation-700)", lineHeight: 1.5 }}>
                This will mark this document as active and clear any scheduled retirement date. Save the document afterwards to apply the changes to the live site.
              </p>
            }
            confirmLabel="Restore to Active"
            confirmingLabel="Restoring…"
            heading="Restore this content?"
            modalSlug={restoreModalSlug}
            onConfirm={handleConfirmRestore}
          />

          {/* Modal: Remove Scheduled Archival */}
          <ConfirmationModal
            body={
              <p style={{ margin: 0, color: "var(--theme-elevation-700)", lineHeight: 1.5 }}>
                This will remove the scheduled archival date (<strong>{formatDate(archivedAt)}</strong>) and keep this document active. Save the document afterwards to apply the changes to the live site.
              </p>
            }
            confirmLabel="Remove Archival"
            confirmingLabel="Removing…"
            heading="Remove scheduled archival?"
            modalSlug={cancelModalSlug}
            onConfirm={handleCancelPendingArchival}
          />
        </>
      ) : null}
    </div>
  );
}

A few details here are worth calling out beyond what's visible in the code. useSyncExternalStore with a snapshot that returns true on the client and false on the server is a hydration-safe way to know when the component has mounted in the browser, which matters because the ConfirmationModal instances render null on the server and would otherwise cause a hydration mismatch if rendered unconditionally. useFormFields is read alongside useField so the pill and buttons reflect the live form state even before the local useField value has settled, which avoids a flash of the wrong status pill when the document first loads. Each modal gets a slug scoped to the document ID, so multiple documents open in different browser tabs never collide on the same modal instance.

Note that every state change here calls Payload's setValue from useField, not a direct API request. Nothing is persisted until the editor clicks the actual document save button, which keeps this consistent with how every other field in Payload's admin panel behaves.

Step 4: List View Integration

An archived item disappearing from the default collection list is only useful if editors can still find it when they need to. Two pieces handle this: a server-side baseFilter that reads the current view from the URL, and a filter bar rendered above the table that lets editors switch views.

The Query-Aware baseFilter

typescript
// File: src/payload/features/archive/baseFilter.ts
import type { BaseFilter, Where } from "payload";

export const activeContentBaseFilter: BaseFilter = ({ req }): Where => {
  const searchParam =
    req?.searchParams?.get("archived") ??
    (req?.query?.archived as string | undefined);

  if (searchParam === "true") {
    return { archived: { equals: true } };
  }

  if (searchParam === "all") {
    return {};
  }

  // Default: active content only
  return {
    or: [
      { archived: { equals: false } },
      { archived: { exists: false } },
    ],
  };
};

admin.baseFilter runs on the server for every request to the collection's list view, which is what makes reading req.searchParams here safe. The default branch checks for archived: { exists: false } alongside equals: false because any document created before this feature shipped won't have the field populated at all, and treating a missing field as "not archived" avoids a one-time migration.

The Filter Bar Component

tsx
// File: src/payload/admin-components/archive/ArchiveListFilterBar.tsx
"use client";

import React, { Suspense } from "react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Button, Pill } from "@payloadcms/ui";

function ArchiveListFilterBarContent({ collectionSlug }: { collectionSlug?: string }) {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();

  const archivedParam = searchParams?.get("archived");
  const currentView =
    archivedParam === "true" ? "archived" : archivedParam === "all" ? "all" : "active";

  const handleFilterSelect = (view: "active" | "archived" | "all") => {
    const params = new URLSearchParams(searchParams?.toString() ?? "");
    params.delete("page");
    if (view === "active") {
      params.delete("archived");
    } else {
      params.set("archived", view === "archived" ? "true" : "all");
    }
    const qs = params.toString();
    router.push(`${pathname}${qs ? `?${qs}` : ""}`);
  };

  return (
    <div
      style={{
        display: "flex",
        alignItems: "center",
        justifyContent: "space-between",
        flexWrap: "wrap",
        gap: "0.75rem",
        padding: "0.6rem 0.85rem",
        marginBottom: "1rem",
        borderRadius: "var(--style-radius-m, 6px)",
        border: "1px solid var(--theme-elevation-150)",
        backgroundColor: "var(--theme-elevation-50)",
      }}
    >
      <div style={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: "0.5rem" }}>
        <span style={{ fontSize: "0.85rem", fontWeight: 600, color: "var(--theme-elevation-700)" }}>
          Lifecycle View:
        </span>
        <Button
          buttonStyle={currentView === "active" ? "primary" : "secondary"}
          margin={false}
          onClick={() => handleFilterSelect("active")}
          size="small"
        >
          Active Content
        </Button>
        <Button
          buttonStyle={currentView === "archived" ? "primary" : "secondary"}
          margin={false}
          onClick={() => handleFilterSelect("archived")}
          size="small"
        >
          Archived Only
        </Button>
        <Button
          buttonStyle={currentView === "all" ? "primary" : "secondary"}
          margin={false}
          onClick={() => handleFilterSelect("all")}
          size="small"
        >
          All Items
        </Button>
        {currentView === "archived" ? (
          <Pill pillStyle="error" size="small">Showing Archived</Pill>
        ) : null}
      </div>

      <div>
        <Link
          href={`/admin/archive${collectionSlug ? `?collection=${collectionSlug}` : ""}`}
          style={{
            fontSize: "0.825rem",
            color: "var(--theme-elevation-800)",
            textDecoration: "none",
            fontWeight: 500,
          }}
        >
          <span>Archive Workspace ↗</span>
        </Link>
      </div>
    </div>
  );
}

export function ArchiveListFilterBar(props: { collectionSlug?: string }) {
  return (
    <Suspense fallback={null}>
      <ArchiveListFilterBarContent {...props} />
    </Suspense>
  );
}

The filter bar and the baseFilter communicate purely through the URL query string, with no shared client state between them. Clicking a filter button pushes a new URL, Next.js re-renders the list view, and Payload's server-side baseFilter reads the new archived parameter on the next request. Wrapping the component in Suspense is required because useSearchParams opts the component into Next.js's client-side search-param reading, which needs a Suspense boundary during the initial render.

Registering It on a Collection

typescript
// File: src/payload/collections/Blogs.ts
import { activeContentBaseFilter } from "@/payload/features/archive";

export const Blogs: CollectionConfig = {
  slug: "blogs",
  admin: {
    useAsTitle: "title",
    baseFilter: activeContentBaseFilter,
    components: {
      beforeListTable: [
        "/src/payload/admin-components/archive/ArchiveListFilterBar#ArchiveListFilterBar",
      ],
    },
  },
  // ...
};

This same pair of registrations (baseFilter plus beforeListTable) is what you repeat across every collection that needs archival, whether that's blogs, product pages, job postings, or promotional content.

Step 5: Background Scheduled Archival Task

Scheduling a future retirement date is only half the feature. Something needs to actually flip archived to true once that date arrives, without an editor having to open the document and click a button. Payload's Jobs Queue handles this as a recurring task:

typescript
// File: src/payload/jobs/processScheduledArchives.ts
import type { TaskConfig } from "payload";
import { ARCHIVE_ENABLED_COLLECTIONS } from "./constants";

export const processScheduledArchivesTask: TaskConfig = {
  slug: "process-scheduled-archives",
  handler: async ({ req }) => {
    const nowIso = new Date().toISOString();
    let transitionedCount = 0;

    for (const collection of ARCHIVE_ENABLED_COLLECTIONS) {
      const dueDocs = await req.payload.find({
        collection,
        where: {
          and: [
            { or: [{ archived: { equals: false } }, { archived: { exists: false } }] },
            { archivedAt: { exists: true, less_than_equal: nowIso } },
          ],
        },
        limit: 100,
        overrideAccess: true,
        req,
      });

      for (const doc of dueDocs.docs) {
        await req.payload.update({
          collection,
          id: doc.id,
          data: { archived: true },
          overrideAccess: true,
          req,
        });
        transitionedCount++;
      }
    }

    return {
      output: { status: "completed", transitionedCount },
    };
  },
};

Wiring this to run every ten minutes through Payload's job scheduler covers the gap between "scheduled" and "archived" without any editor intervention. overrideAccess: true is deliberate here: this task runs as a system process, not on behalf of a logged-in user, and should not be blocked by access-control rules written for editorial roles. ARCHIVE_ENABLED_COLLECTIONS keeps the task generic across every collection that has opted into this feature rather than hardcoding collection slugs into the handler itself.

This kind of background write pattern, where a task reads a batch of documents and updates each one through Payload's Local API, is the same shape covered in more depth in how to safely manipulate Payload CMS data in hooks without hanging or recursion. If this task is running against a collection with its own hooks that write related documents, that guide is worth reading before scaling this beyond a handful of collections.

Step 6: Frontend and Search Isolation

Everything up to this point controls what editors see in the admin panel. The last piece controls what the public site and its search index ever see.

A Single Shared Query Boundary

typescript
// File: src/payload/data/content/shared.ts
import type { Where } from "payload";

export function publishedVisibilityWhere(includeDrafts = false): Where {
  const baseWhere: Where = {
    or: [
      { archived: { equals: false } },
      { archived: { exists: false } },
    ],
  };

  if (!includeDrafts) {
    return {
      and: [
        baseWhere,
        { _status: { equals: "published" } },
      ],
    };
  }

  return baseWhere;
}

Every frontend data-fetching function, every sitemap generator, and every page-level query needs to run through this helper rather than writing its own where clause. Centralizing the archival check into one function is what keeps a page component, a sitemap route, and a related-content widget from independently making three slightly different decisions about what counts as "visible."

Excluding Archived Content from Search

typescript
// File: src/payload/search/searchSkipSync.ts
export function searchSkipSync({ doc }: { doc: Record<string, unknown> }): boolean {
  // If the document is archived, exclude it from search index
  if (doc.archived === true) return true;
  return false;
}

Payload's search plugin calls a skipSync function on every document write to decide whether it belongs in the search index. Returning true for archived documents means the moment a document flips to archived: true, whether through the sidebar button or the background job, it's dropped from search results on the next sync without any separate cleanup step.

FAQ

What happens to relation fields pointing to an archived document? Nothing at the database level. The relationship field still resolves and still returns the referenced document's data if you query it directly, since the document was never deleted. It's the responsibility of each frontend query and template to check archived and archivedAt before rendering that related content, which is exactly what publishedVisibilityWhere is for.

How is this different from Payload's built-in draft and publish system? Draft/publish (_status) models whether a document is ready to go live. This system models whether previously live content should still be visible now, including a specific future date for that to happen automatically. A document can be published and archived at the same time, or in draft and never archived at all; the two states track different questions.

Can one document be scheduled for archival and unscheduled later? Yes. Clicking "Remove Archival" on a scheduled document clears both archived and archivedAt, returning it to the active state with no history of the cancelled schedule kept in those two fields.

What happens if the Payload Jobs Queue isn't configured to run this task on a schedule? Documents with a past archivedAt and archived: false will still be correctly excluded from search via searchSkipSync only once archived actually flips to true, so an unconfigured job leaves those documents live and searchable past their scheduled date. Editors can still trigger the transition manually by opening the document and clicking through the archive flow, but automatic transitions require the job to be registered and running.

Does archiving affect existing static pages already generated for that content? Not automatically. If pages are statically generated or cached, an archived document's page will keep serving from cache until the next revalidation or rebuild picks up the change. Pair publishedVisibilityWhere with your framework's revalidation strategy so archival takes effect on the live site within an acceptable window.

Wrapping Up

This system solves a specific problem: retiring content without breaking the relational and SEO guarantees that hard deletion destroys. Six pieces work together to make that possible — two hidden database fields and a presentational UI field, a validation hook that keeps the two dates from ever contradicting each other, a sidebar component that walks editors through three lifecycle states with proper confirmation dialogs, a query-aware list view that lets editors switch between active, archived, and all content, a background job that handles scheduled transitions automatically, and a single shared query helper that keeps archived content off the live site and out of search.

If you're extending this to your own collections, start with the schema and validation hook, get the sidebar component working on one collection, and only then wire up the list view filter and the background job. Each layer is independently testable, and getting the invariants right in the validation hook early saves a lot of debugging further up the stack.

Let me know in the comments if you have questions about adapting this to your own collection setup, and subscribe for more practical Payload CMS guides.

Thanks, Matija