shadimage
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 · light

Registry 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)}`
}

More from payload-components

Browse all components in this library →