payload-components
blockcollection-querysource: ok · refreshed
Collection Query
A Pages block that queries the starter Posts collection by category or manual selection and renders grid, list, or featured cards.
npx shadcn add "https://www.payload-components.xyz/r/collection-query.json"free
Where it goes
feature-grid
Interaction
dropdown
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightRegistry dependencies
badgecard
Files6 · source cached 10/7/2026, 4:45:09 AM
payload-components/source/blocks/shared/PostCard.tsx
import Link from 'next/link'
import React from 'react'
import type { Post } from '@/payload-types'
import { Media } from '@/components/Media'
import { Badge } from '@/components/ui/badge'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { cn } from '@/utilities/ui'
export type PostCardData = Pick<Post, 'categories' | 'meta' | 'publishedAt' | 'slug' | 'title'>
export function PostCard({
className,
post,
variant = 'grid',
}: {
className?: string
post: PostCardData
variant?: 'featured' | 'grid' | 'list'
}) {
const image = post.meta?.image
const description = post.meta?.description?.replace(/\s+/g, ' ').trim()
const href = `/posts/${post.slug}`
const publishedAt = post.publishedAt ? new Date(post.publishedAt) : undefined
const categories = (post.categories ?? []).flatMap((category) =>
typeof category === 'object' && category?.title ? [category.title] : [],
)
return (
<Card
className={cn(
'group gap-0 overflow-hidden border-border/70 bg-card py-0 shadow-none transition-colors hover:border-foreground/25',
variant === 'list' && 'sm:grid sm:grid-cols-[14rem_minmax(0,1fr)]',
className,
)}
>
<div className={cn('overflow-hidden bg-muted', variant === 'featured' && 'lg:aspect-[2/1]')}>
{image && typeof image === 'object' ? (
<Media
imgClassName="aspect-[16/10] h-full w-full object-cover transition-transform duration-500 motion-reduce:transform-none group-hover:scale-[1.02]"
resource={image}
size={variant === 'list' ? '14rem' : '(min-width: 1024px) 33vw, 100vw'}
/>
) : (
<div className="aspect-[16/10] bg-muted" />
)}
</div>
<div className="flex min-w-0 flex-col">
<CardHeader className="flex flex-col gap-3 p-5">
<div className="flex flex-wrap items-center gap-2">
{categories.slice(0, 2).map((category, index) => (
<Badge key={`${category}-${index}`} variant="outline" className="font-normal">
{category}
</Badge>
))}
{publishedAt && !Number.isNaN(publishedAt.valueOf()) ? (
<time
className="text-xs text-muted-foreground"
dateTime={publishedAt.toISOString()}
>
{new Intl.DateTimeFormat('en', {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'UTC',
}).format(publishedAt)}
</time>
) : null}
</div>
<CardTitle className="text-balance text-xl font-medium leading-snug tracking-title">
<Link
className="rounded-sm outline-none transition-colors group-hover:text-brand focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
href={href}
>
{post.title}
</Link>
</CardTitle>
</CardHeader>
{description ? (
<CardContent className="px-5 pb-5 pt-0">
<CardDescription className="line-clamp-3 text-sm leading-6">
{description}
</CardDescription>
</CardContent>
) : null}
</div>
</Card>
)
}
payload-components/source/blocks/CollectionQuery/config.ts
import type { Block } from 'payload'
export const CollectionQuery: Block = {
slug: 'collectionQuery',
// Existing apps must migrate stored data before adopting this identifier:
// https://www.payload-components.xyz/docs/registry#installed-source-and-migrations
dbName: 'pc_col_que',
interfaceName: 'CollectionQueryBlock',
fields: [
{
name: 'populateBy',
type: 'select',
defaultValue: 'collection',
options: [
{ label: 'Latest posts', value: 'collection' },
{ label: 'Manual selection', value: 'selection' },
],
required: true,
},
{
name: 'categories',
type: 'relationship',
admin: {
condition: (_, siblingData) => siblingData.populateBy === 'collection',
},
hasMany: true,
relationTo: 'categories',
},
{
name: 'limit',
type: 'number',
admin: {
condition: (_, siblingData) => siblingData.populateBy === 'collection',
step: 1,
},
defaultValue: 6,
max: 24,
min: 1,
required: true,
},
{
name: 'sort',
type: 'select',
admin: {
condition: (_, siblingData) => siblingData.populateBy === 'collection',
},
defaultValue: '-publishedAt',
options: [
{ label: 'Newest first', value: '-publishedAt' },
{ label: 'Oldest first', value: 'publishedAt' },
{ label: 'Title A–Z', value: 'title' },
{ label: 'Title Z–A', value: '-title' },
],
required: true,
},
{
name: 'selectedDocs',
type: 'relationship',
admin: {
condition: (_, siblingData) => siblingData.populateBy === 'selection',
},
hasMany: true,
relationTo: 'posts',
},
{
name: 'enablePagination',
type: 'checkbox',
defaultValue: false,
admin: { condition: (_, siblingData) => siblingData.populateBy === 'collection' },
},
{
name: 'enableFilters',
type: 'checkbox',
defaultValue: false,
admin: {
condition: (_, siblingData) => siblingData.populateBy === 'collection',
description:
'Show category links. Selecting categories above limits the available choices.',
},
},
{
name: 'queryKey',
type: 'text',
custom: { payloadComponents: { localization: 'global' } },
defaultValue: 'posts',
admin: {
description:
'Fallback URL namespace when rendering outside a saved Page block. Use a unique key per instance.',
},
},
{
name: 'emptyMessage',
type: 'text',
custom: { payloadComponents: { localization: 'localized' } },
defaultValue: 'No posts found. Try another category or check back soon.',
},
{
name: 'layout',
type: 'select',
defaultValue: 'grid',
options: [
{ label: 'Grid', value: 'grid' },
{ label: 'List', value: 'list' },
{ label: 'Featured', value: 'featured' },
],
required: true,
},
],
labels: {
plural: 'Collection Query Blocks',
singular: 'Collection Query',
},
}
payload-components/source/blocks/CollectionQuery/Component.tsx
import React from 'react'
import type { TypedLocale } from 'payload'
import type { CollectionQueryBlock as CollectionQueryBlockData } from '@/payload-types'
import { cn } from '@/utilities/ui'
import { CollectionBrowser } from './Browser'
import { queryCategories, queryPosts, type CollectionOptions } from './query'
import {
readCollectionRequest,
type CollectionRequest,
type CollectionSearchParams,
} from './navigation'
type Props = CollectionQueryBlockData & {
id?: string
className?: string
disableInnerContainer?: boolean
locale?: TypedLocale
searchParams?: CollectionSearchParams
}
export const CollectionQueryBlock: React.FC<Props> = async ({
className,
disableInnerContainer,
id,
layout = 'grid',
emptyMessage = 'No posts found. Try another category or check back soon.',
locale,
searchParams,
...data
}) => {
const options: CollectionOptions = {
categories: data.categories?.map((category) =>
typeof category === 'object' ? category.id : category,
),
enableFilters: data.enableFilters,
enablePagination: data.enablePagination,
limit: data.limit,
populateBy: data.populateBy,
selectedDocs: data.selectedDocs?.map((post) => (typeof post === 'object' ? post.id : post)),
sort: data.sort,
locale,
}
const key = `collection-${id || data.queryKey || 'posts'}`
const params = new URLSearchParams()
for (const [name, value] of Object.entries(searchParams ?? {})) {
for (const entry of Array.isArray(value) ? value : value === undefined ? [] : [value]) {
params.append(name, entry)
}
}
const request = readCollectionRequest(params, key)
const [initial, categories] = await Promise.all([
queryPosts(options, request),
queryCategories(options),
])
async function load(request: CollectionRequest) {
'use server'
return queryPosts(options, request)
}
return (
<section className={cn('container', className)} id={id ? `block-${id}` : undefined}>
<div className="contents">
<CollectionBrowser
categories={categories}
disableInnerContainer={disableInnerContainer}
emptyMessage={emptyMessage || 'No posts found.'}
initial={initial}
initialSearch={params.toString()}
layout={layout || 'grid'}
load={load}
pagination={data.populateBy !== 'selection' && Boolean(data.enablePagination)}
queryKey={key}
/>
</div>
</section>
)
}
payload-components/source/blocks/CollectionQuery/Browser.tsx
'use client'
import React, { startTransition, useEffect, useRef, useState } from 'react'
import { PostCard } from '@/blocks/shared/PostCard'
import { cn } from '@/utilities/ui'
import { collectionHref, readCollectionRequest, type CollectionRequest } from './navigation'
import type { CollectionResult } from './query'
type Props = {
categories: { id: string; title: string }[]
disableInnerContainer?: boolean
emptyMessage: string
initial: CollectionResult
initialSearch: string
layout: 'featured' | 'grid' | 'list'
load: (request: CollectionRequest) => Promise<CollectionResult>
pagination: boolean
queryKey: string
}
const navigationEvent = 'payload-collection-navigation'
const linkClass =
'rounded-md border border-border px-3 py-2 text-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
export function CollectionBrowser({
categories,
disableInnerContainer,
emptyMessage,
initial,
initialSearch,
layout,
load,
pagination,
queryKey,
}: Props) {
const [result, setResult] = useState(initial)
const [previousInitial, setPreviousInitial] = useState(initial)
const [search, setSearch] = useState(initialSearch)
const [pending, setPending] = useState(false)
const [error, setError] = useState(false)
const retry = useRef<() => void>(() => {})
// A new server render (including live preview) supersedes the old result.
if (initial !== previousInitial) {
setPreviousInitial(initial)
setResult(initial)
setPending(false)
setError(false)
}
useEffect(() => {
let active = true
let sequence = 0
let previous = JSON.stringify({ category: initial.category, page: initial.page })
const sync = (force = false) => {
const currentSearch = window.location.search
setSearch(currentSearch)
const request = readCollectionRequest(new URLSearchParams(currentSearch), queryKey)
if (!pagination) request.page = 1
if (!categories.length) request.category = undefined
const signature = JSON.stringify(request)
if (!force && previous === signature) return
previous = signature
const ticket = ++sequence
setPending(true)
setError(false)
startTransition(async () => {
try {
const next = await load(request)
if (!active || ticket !== sequence) return
setResult(next)
// Replace stale page numbers without adding a history entry.
if (next.page !== request.page || next.category !== request.category) {
const href = collectionHref(window.location.search, queryKey, next)
window.history.replaceState(window.history.state, '', href)
previous = JSON.stringify({ category: next.category, page: next.page })
window.dispatchEvent(new Event(navigationEvent))
}
} catch {
if (active && ticket === sequence) setError(true)
} finally {
if (active && ticket === sequence) setPending(false)
}
})
}
const onNavigation = () => sync()
retry.current = () => sync(true)
sync()
window.addEventListener('popstate', onNavigation)
window.addEventListener(navigationEvent, onNavigation)
return () => {
active = false
window.removeEventListener('popstate', onNavigation)
window.removeEventListener(navigationEvent, onNavigation)
}
}, [categories, initial, load, pagination, queryKey])
const navigate = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
return
event.preventDefault()
window.history.pushState(window.history.state, '', event.currentTarget.href)
window.dispatchEvent(new Event(navigationEvent))
}
return (
<div
className={cn('space-y-6 scroll-mt-24', !disableInnerContainer && 'mx-auto max-w-6xl')}
id={queryKey}
>
{categories.length > 0 ? (
<nav aria-label="Filter posts by category" className="flex flex-wrap gap-2">
{[{ id: '', title: 'All posts' }, ...categories].map((category) => (
<a
aria-current={(result.category || '') === category.id ? 'true' : undefined}
className={cn(
linkClass,
(result.category || '') === category.id &&
'border-foreground bg-foreground text-background hover:bg-foreground',
)}
href={collectionHref(search, queryKey, {
category: category.id || undefined,
page: 1,
})}
key={category.id}
onClick={navigate}
>
{category.title}
</a>
))}
</nav>
) : null}
<div aria-busy={pending}>
{result.docs.length > 0 ? (
<div
className={cn('gap-6', {
'flex flex-col': layout === 'list',
'grid md:grid-cols-2 lg:grid-cols-3': layout === 'grid',
'grid lg:grid-cols-2': layout === 'featured',
})}
>
{result.docs.map((post, index) => (
<PostCard
className={cn({ 'lg:col-span-2': layout === 'featured' && index === 0 })}
key={post.id}
post={post}
variant={
layout === 'list'
? 'list'
: layout === 'featured' && index === 0
? 'featured'
: 'grid'
}
/>
))}
</div>
) : (
<p className="rounded-xl border border-dashed border-border bg-muted/30 px-6 py-12 text-center text-sm text-muted-foreground">
{emptyMessage}
</p>
)}
</div>
<div aria-atomic="true" aria-live="polite" className="text-sm text-muted-foreground">
{pending
? 'Loading posts…'
: error
? 'Posts could not be loaded. Please try again.'
: `${result.totalDocs} ${result.totalDocs === 1 ? 'post' : 'posts'}`}
</div>
{error ? (
<button className={linkClass} onClick={() => retry.current()} type="button">
Try again
</button>
) : null}
{pagination && result.totalPages > 1 ? (
<nav
aria-label="Posts pagination"
className="flex flex-wrap items-center justify-between gap-3"
>
{result.page > 1 ? (
<a
className={linkClass}
href={collectionHref(search, queryKey, {
category: result.category,
page: result.page - 1,
})}
onClick={navigate}
>
Previous
</a>
) : (
<span className="px-3 py-2 text-sm text-muted-foreground">Previous</span>
)}
<span className="text-sm text-muted-foreground">
Page {result.page} of {result.totalPages}
</span>
{result.page < result.totalPages ? (
<a
className={linkClass}
href={collectionHref(search, queryKey, {
category: result.category,
page: result.page + 1,
})}
onClick={navigate}
>
Next
</a>
) : (
<span className="px-3 py-2 text-sm text-muted-foreground">Next</span>
)}
</nav>
) : null}
</div>
)
}
payload-components/source/blocks/CollectionQuery/query.ts
import configPromise from '@payload-config'
import { draftMode, headers } from 'next/headers'
import { getPayload, type TypedLocale, type Where } from 'payload'
import type { CollectionQueryBlock, Post } from '@/payload-types'
import type { PostCardData } from '@/blocks/shared/PostCard'
import type { CollectionRequest } from './navigation'
export type CollectionOptions = Pick<
CollectionQueryBlock,
| 'categories'
| 'enableFilters'
| 'enablePagination'
| 'limit'
| 'populateBy'
| 'selectedDocs'
| 'sort'
> & { locale?: TypedLocale }
export type CollectionResult = {
category?: string
docs: Array<PostCardData & Pick<Post, 'id'>>
page: number
totalDocs: number
totalPages: number
}
const sortOptions = new Set(['-publishedAt', 'publishedAt', 'title', '-title'])
const relationId = (value: { id: number | string } | number | string) =>
typeof value === 'object' ? value.id : value
async function queryContext() {
const payload = await getPayload({ config: configPromise })
const { user } = await payload.auth({ headers: await headers() })
const { isEnabled } = await draftMode()
// A preview cookie alone never grants access to drafts.
return { payload, user, draft: Boolean(isEnabled && user) }
}
export async function queryCategories(options: CollectionOptions) {
if (!options.enableFilters || options.populateBy === 'selection') return []
const { payload, user } = await queryContext()
const ids = (options.categories ?? []).map(relationId)
const result = await payload.find({
collection: 'categories',
depth: 0,
limit: 100,
locale: options.locale,
overrideAccess: false,
user,
select: { title: true },
sort: ['title', 'id'],
where: ids.length ? { id: { in: ids } } : undefined,
})
return result.docs.map(({ id, title }) => ({ id: String(id), title }))
}
export async function queryPosts(
options: CollectionOptions,
request: CollectionRequest = {},
): Promise<CollectionResult> {
const { payload, user, draft } = await queryContext()
const selection = options.populateBy === 'selection'
const selectedIds = [...new Set((options.selectedDocs ?? []).map(relationId))]
const categoryIds = (options.categories ?? []).map(relationId)
const page =
!selection && options.enablePagination && Number.isSafeInteger(request?.page)
? Math.min(Math.max(request.page ?? 1, 1), 10000)
: 1
// Treat server-function arguments as untrusted. A visitor can narrow the
// editor's selection, but cannot expand it or change its limit/sort/locale.
const category =
!selection &&
options.enableFilters &&
typeof request?.category === 'string' &&
request.category.length <= 128 &&
request.category.length > 0
? request.category
: undefined
const clauses: Where[] = []
if (!draft) clauses.push({ _status: { equals: 'published' } })
if (selection) clauses.push({ id: { in: selectedIds } })
if (!selection && categoryIds.length) clauses.push({ categories: { in: categoryIds } })
if (category) clauses.push({ categories: { equals: category } })
if (selection && selectedIds.length === 0)
return { docs: [], page: 1, totalDocs: 0, totalPages: 1 }
const limit = selection
? selectedIds.length
: Math.min(Math.max(Math.trunc(options.limit || 6), 1), 24)
const find = (requestedPage: number) =>
payload.find({
collection: 'posts',
depth: 1,
draft,
limit,
locale: options.locale,
overrideAccess: false,
user,
page: requestedPage,
pagination: !selection && Boolean(options.enablePagination),
select: { categories: true, meta: true, publishedAt: true, slug: true, title: true },
sort: [options.sort && sortOptions.has(options.sort) ? options.sort : '-publishedAt', 'id'],
where: { and: clauses },
})
let result = await find(page)
// Content removal or an old bookmarked URL can move the last page. Clamp and
// query once more so the visitor sees the last available page instead of a void.
if (!selection && options.enablePagination && page > Math.max(result.totalPages, 1)) {
result = await find(Math.max(result.totalPages, 1))
}
const docs = selection
? selectedIds.flatMap((id) => result.docs.filter((post) => String(post.id) === String(id)))
: result.docs
return {
category,
docs: docs.map(({ id, categories, meta, publishedAt, slug, title }) => ({
id,
categories,
meta,
publishedAt,
slug,
title,
})),
page: result.page || 1,
totalDocs: result.totalDocs,
totalPages: Math.max(result.totalPages, 1),
}
}
payload-components/source/blocks/CollectionQuery/navigation.ts
export type CollectionRequest = { category?: string; page?: number }
export type CollectionSearchParams = Record<string, string | string[] | undefined>
export function readCollectionRequest(search: URLSearchParams, key: string): CollectionRequest {
const value = search.get(`${key}-page`)
const page = value && /^\d+$/.test(value) ? Number(value) : 1
return {
category: search.get(`${key}-category`) || undefined,
page: Number.isSafeInteger(page) && page > 0 ? Math.min(page, 10000) : 1,
}
}
export function collectionHref(search: string, key: string, request: CollectionRequest) {
const params = new URLSearchParams(search)
params.delete(`${key}-page`)
params.delete(`${key}-category`)
if (request.page && request.page > 1) params.set(`${key}-page`, String(request.page))
if (request.category) params.set(`${key}-category`, request.category)
return `?${params.toString()}#${encodeURIComponent(key)}`
}