---
title: "Payload CMS Archival System: Schedule Content Retirement"
slug: "payload-cms-archival-scheduled-retirement"
published: "2026-08-27"
updated: "2026-08-29"
validated: "2026-08-29"
categories:
  - "Payload"
tags:
  - "Payload CMS archival"
  - "content archival Payload CMS"
  - "schedule content retirement"
  - "soft-archival"
  - "archivedAt"
  - "admin.baseFilter"
  - "Payload Jobs Queue"
  - "publishedVisibilityWhere"
  - "searchSkipSync"
  - "@payloadcms/ui"
  - "sidebar UI"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "payload cms v3"
  - "@payloadcms/ui"
  - "typescript"
  - "react"
  - "next.js"
status: "stable"
llm-purpose: "Payload CMS archival: schedule or instantly retire content without deleting it—preserve relations, stop 404s, remove from search, and restore when needed."
llm-prereqs:
  - "Access to Payload CMS v3"
  - "Access to @payloadcms/ui"
  - "Access to TypeScript"
  - "Access to React"
  - "Access to Next.js"
llm-outputs:
  - "Completed outcome: Payload CMS archival: schedule or instantly retire content without deleting it—preserve relations, stop 404s, remove from search, and restore when needed."
---

**Summary Triples**
- (archival data model, uses, archivedAt (Date) and isArchived (boolean) fields to represent retired state)
- (beforeValidate hook, ensures, isArchived value is derived from archivedAt and keeps data integrity on save)
- (admin list views, hide archived items using, admin.baseFilter that filters by isArchived/archivedAt)
- (scheduled retirement, implements with, Payload Jobs Queue jobs that transition records when archivedAt is reached)
- (admin UI, provides, sidebar archive/restore buttons built with @payloadcms/ui components (ConfirmationModal, Pill, useField, useFormFields))
- (search sync, excludes archived items by, using searchSkipSync or publishedVisibilityWhere configuration to prevent index updates)
- (restore action, performs, clearing archivedAt/isArchived and re-syncing to search (if needed))
- (soft-archival benefit, preserves, foreign-key relations, audit history, and internal links (avoids 404s))
- (validation and CI, verified by, pnpm typecheck, pnpm lint; full test suite (694 tests) passing)
- (list filtering nuance, accounts for, query-aware admin.baseFilter so relationships and relation-driven queries still resolve)

### {GOAL}
Payload CMS archival: schedule or instantly retire content without deleting it—preserve relations, stop 404s, remove from search, and restore when needed.

### {PREREQS}
- Access to Payload CMS v3
- Access to @payloadcms/ui
- Access to TypeScript
- Access to React
- Access to Next.js

### {STEPS}
1. Define archival schema fields
2. Add server-side validation hook
3. Build the interactive sidebar UI
4. Integrate list view filters and baseFilter
5. Implement scheduled archival job
6. Enforce frontend visibility and search isolation
7. Test, deploy, and monitor behavior

<!-- llm:goal="Payload CMS archival: schedule or instantly retire content without deleting it—preserve relations, stop 404s, remove from search, and restore when needed." -->
<!-- llm:prereq="Access to Payload CMS v3" -->
<!-- llm:prereq="Access to @payloadcms/ui" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to React" -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:output="Completed outcome: Payload CMS archival: schedule or instantly retire content without deleting it—preserve relations, stop 404s, remove from search, and restore when needed." -->

# Payload CMS Archival System: Schedule Content Retirement
> Payload CMS archival: schedule or instantly retire content without deleting it—preserve relations, stop 404s, remove from search, and restore when needed.
Matija Žiberna · 2026-08-27

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 <a href="https://www.buildwithmatija.com/blog/payload-cms-custom-admin-ui-components-guide">custom admin fields and views with `@payloadcms/ui`</a>. 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.

| Approach | What happens | Trade-off |
|---|---|---|
| Hard delete | Document row removed from the database | Breaks 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 users | Good 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 visibility | Requires 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.

```mermaid
graph TD
    subgraph Admin Panel
        A["Editor Form (Sidebar UI)"] -->|"Schedule / Instant"| B["Document (archived, archivedAt)"]
        C["Collection List View"] -->|"admin.baseFilter + beforeListTable"| D["Active Only / Archived / All"]
        E["Archive Workspace (/admin/archive)"] -->|"Search, Filter, Restore"| B
    end

    subgraph Automation
        F["Payload Jobs Queue (processScheduledArchivesTask)"] -->|"Transitions Expired Items"| B
        F -->|"Prunes"| G["Search Index & CDN Caches"]
    end

    subgraph Public Website
        B -->|"publishedVisibilityWhere"| H["Live Frontend Pages (Active Only)"]
    end
```

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 <a href="https://www.buildwithmatija.com/blog/payload-cms-hooks-safe-data-manipulation-postgresql">how to safely manipulate Payload CMS data in hooks without hanging or recursion</a>. 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

## LLM Response Snippet
```json
{
  "goal": "Payload CMS archival: schedule or instantly retire content without deleting it—preserve relations, stop 404s, remove from search, and restore when needed.",
  "responses": [
    {
      "question": "What does the article \"Payload CMS Archival System: Schedule Content Retirement\" cover?",
      "answer": "Payload CMS archival: schedule or instantly retire content without deleting it—preserve relations, stop 404s, remove from search, and restore when needed."
    }
  ]
}
```