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.
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:
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.
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.
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 on the server is a hydration-safe way to know when the component has mounted in the browser, which matters because the instances render on the server and would otherwise cause a hydration mismatch if rendered unconditionally. is read alongside so the pill and buttons reflect the live form state even before the local 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.
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 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.
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:
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.
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.tsexportfunctionsearchSkipSync({ doc }: { doc: Record<string, unknown> }): boolean {
// If the document is archived, exclude it from search indexif (doc.archived === true) returntrue;
returnfalse;
}
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
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
// 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 */
<FieldDescriptiondescription="Retires previously published content from the live public site without deleting it."path="archived"
/>
<>
{/* Modal: Schedule / Immediate Archival */}
<ConfirmationModalbody={
<divstyle={{display: "flex", flexDirection: "column", gap: "1rem", marginTop: "0.5rem" }}><pstyle={{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><divstyle={{display: "flex", flexDirection: "column", gap: "0.35rem" }}><FieldLabelhtmlFor="archive-date-input"label="Effective Archival Date & Time" /><inputid="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}
/>
<FieldDescriptiondescription={isSelectedDateFuture
? "🗓️ Settingafuturedatewillkeepthisdocumentactiveuntilthatdate, thenretireitautomatically."
: "⚠️ Settingthecurrentorpastdatewillretirethisdocumentimmediatelyuponsaving."
}
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 */}
<ConfirmationModalbody={
<pstyle={{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 */}
<ConfirmationModalbody={
<pstyle={{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}
/>
</>