PayloadCMS — Trash, Query Presets, Folders & Group By
Four data-management features shipped during Payload v3 (2025). Each is enabled by a single flag on a collection and, where it needs storage, auto-creates a managed collection. None require a custom UI.
Feature Map
| Feature | Collection flag | Auto-created collection | Admin effect |
|---|---|---|---|
| Trash (soft delete) | trash: true |
— (adds a deletedAt field) |
Trash view + Restore / Permanently Delete actions |
| Query Presets | enableQueryPresets: true |
payload-query-presets |
Save/load/share named list-view configs |
| Folders | folders: true |
payload-folders |
"Browse by folder" view; folder field on docs |
| Group By | admin.groupBy: true |
— | Grouped list view by a shared field |
Each of these alters the database schema (a new column, table, or relationship). After enabling any of them in Postgres/SQLite, generate and run a migration — see the
cli-recipesskill.
Trash (Soft Delete)
Set trash: true to make deletes reversible. Payload injects a deletedAt timestamp field; "deleting" a document just stamps deletedAt instead of removing the row.
// src/collections/Posts.ts
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
trash: true, // defaults to false
fields: [
{ name: 'title', type: 'text', required: true },
],
}
Admin: A new /collections/:slug/trash route lists soft-deleted docs with bulk Restore, Delete (permanent), and Empty Trash actions. The list view's delete modal gains a checkbox to bypass trash and delete permanently. A trashed document's edit view is read-only, offering Restore and Permanently Delete.
Local API — control which set you query with the trash param:
// All docs including trashed
await payload.find({ collection: 'posts', trash: true })
// Only trashed
await payload.find({
collection: 'posts',
trash: true,
where: { deletedAt: { exists: true } },
})
// Only non-trashed (default behavior)
await payload.find({ collection: 'posts', trash: false })
Restore by clearing the timestamp; permanently delete by passing trash: true to delete:
// Restore — clear deletedAt
await payload.update({ collection: 'posts', id, data: { deletedAt: null }, req })
// Permanent delete (skip trash)
await payload.delete({ collection: 'posts', id, trash: true, req })
REST mirrors the param: GET /api/posts?trash=true, GET /api/posts?trash=true&where[deletedAt][exists]=true, GET /api/posts?trash=false.
Access control: all three operations (soft delete, restore, permanent delete) run through the collection's delete access function. Differentiate by inspecting the data argument — on a soft-delete data.deletedAt is set; on a permanent delete data is undefined:
// src/collections/Posts.ts
access: {
delete: ({ req: { user }, data }) => {
if (!user) return false
if (user.roles?.includes('admin')) return true
if (data?.deletedAt) return true // allow trashing
return false // deny permanent delete to non-admins
},
}
When versions are enabled, a trashed document's versions cannot be restored until the document itself is restored, but version history stays visible in its edit view.
Query Presets
Set enableQueryPresets: true so users can save a List View's filters, columns, and sort order as a named preset and reload it later. Presets are stored in the auto-created payload-query-presets collection, so users can define as many as they like.
// src/collections/Orders.ts
export const Orders: CollectionConfig = {
slug: 'orders',
enableQueryPresets: true,
fields: [/* ... */],
}
Sharing & access is configured under the root queryPresets key in payload.config.ts:
// src/payload.config.ts
import { buildConfig } from 'payload'
export default buildConfig({
collections: [Orders /* ... */],
queryPresets: {
// Static, config-level rules — NOT user-editable
access: {
read: ({ req: { user } }) => Boolean(user),
update: ({ req: { user } }) => user?.roles?.includes('admin'),
},
// Constraint options users pick from in the UI when sharing a preset
constraints: {
read: [
{ label: 'Specific Roles', value: 'specificRoles' /* + custom fields/where */ },
],
},
// Dynamically filter which constraint options a user sees
filterConstraints: ({ req, options }) => options,
// Relabel the presets collection in the admin UI
labels: { singular: 'Saved View', plural: 'Saved Views' },
},
})
Out of the box each preset can be shared as Only Me, Everyone, or Specific Users; queryPresets.constraints extends this with role-based or custom sharing rules, while queryPresets.access defines static rules the user cannot override. Read presets via the Local API like any collection:
const { docs } = await payload.find({ collection: 'payload-query-presets' })
Folders
Set folders: true to let editors organize documents into a folder tree. Payload adds a hidden relationship field on the document pointing to its parent folder (or null), and manages the tree in the auto-created payload-folders collection — which itself has a self-referencing folder field, allowing nested folders.
// src/collections/Media.ts
export const Media: CollectionConfig = {
slug: 'media',
folders: true, // defaults to false
upload: true,
fields: [{ name: 'alt', type: 'text' }],
}
Admin: enabling folders surfaces a Browse by Folder view (the global browseByFolder option, on by default). Editors drag documents between folders there.
Folders are configured globally under the folders key in payload.config.ts:
// src/payload.config.ts
export default buildConfig({
folders: {
browseByFolder: true, // default true — show the folder browser
fieldName: 'folder', // default 'folder' — name of the doc→folder field
slug: 'payload-folders',// default — the folder collection slug
debug: false, // default — reveal hidden folder fields
collectionOverrides: [ // async functions that modify the auto-created folder collection
async ({ collection }) => collection,
],
},
})
Per-collection, folders may also be an object instead of true — e.g. folders: { browseByFolder: false } to keep the collection out of the global folder browser.
Because folders are built on relationship fields, you can scope a query to a folder with a normal where on the folder field (default name folder):
const { docs } = await payload.find({
collection: 'media',
where: { folder: { equals: folderId } },
})
Folders are marked beta in the docs and may change.
Group By
Set admin.groupBy: true to add a grouped List View — results are bucketed by a shared field (e.g. counting items per category, grouping entries by status). The feature is marked beta.
// src/collections/Tickets.ts
export const Tickets: CollectionConfig = {
slug: 'tickets',
admin: {
groupBy: true, // beta — enable grouping in the list view
defaultColumns: ['title', 'status', 'priority'],
},
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'status', type: 'select', options: ['open', 'pending', 'closed'] },
{ name: 'priority', type: 'select', options: ['low', 'high'] },
],
}
In the admin, the list view gains a control to pick which field to group on and renders results in collapsible groups with per-group counts.
Note on the option shape: the boolean form admin.groupBy: true is confirmed in the docs and release notes. The collections config reference also describes it as "grouping by a field," implying a field name may be accepted; richer object forms (e.g. { default, options }) are an open community proposal, not confirmed in stable docs. Use the boolean unless you verify the string/object form against your installed version. For aggregate reads in code, prefer the Local API with explicit grouping/counting rather than relying on the list-view UI.
// src/collections/Articles.ts
export const Articles: CollectionConfig = {
slug: 'articles',
trash: true,
access: {
delete: ({ req: { user }, data }) => {
if (!user) return false
if (user.roles?.includes('admin')) return true
return Boolean(data?.deletedAt) // non-admins may trash, not purge
},
},
fields: [{ name: 'title', type: 'text', required: true }],
}
Editors trash via the normal delete; recover from /collections/articles/trash. Query live docs with trash: false, recoverable ones with trash: true + where: { deletedAt: { exists: true } }.
// src/collections/Tickets.ts
export const Tickets: CollectionConfig = {
slug: 'tickets',
enableQueryPresets: true, // save/share list-view filters, columns, sort
folders: true, // organize tickets into folders
fields: [/* ... */],
}
Presets land in payload-query-presets; sharing options (Only Me / Everyone / Specific Users) come from queryPresets.constraints. Folder assignments live in payload-folders; query a folder with where: { folder: { equals: folderId } }.
What this skill does NOT cover
collections— the fullCollectionConfigsurface (auth, upload, versions, drafts, indexes, custom endpoints).queries— the completewhereoperator set,depth,select, pagination, REST/GraphQL query syntax used by all four features here.access-control— howaccess.delete/overrideAccess/req.userevaluate (Trash leans ondelete; presets define their ownqueryPresets.access).cli-recipes— generating and running the migration each of these flags requires when they add a column, table, or relationship in Postgres/SQLite.