Skip to content

Data Fetching

Server-side data fetching is done per page and per component with Payload’s local API directly. The only shared helper is getPayload in src/lib/payload/local-api.ts, which returns a cached Payload instance. Each route or component defines its own small getX() function next to where the data is used, so the query lives with the thing that renders it.

getPayload wraps Payload’s local API and memoises the instance per request:

import { getPayload } from '@/lib/payload/local-api';
const payload = await getPayload();
const { docs } = await payload.find({ collection: 'pages', limit: 1 });

Everything else — draft handling, the published-only filter, population depth — is set explicitly on each find/findGlobal call.

Pages and components that render previewable content read draftMode() inline and branch the query:

  • draft: isEnabled tells Payload to return the latest (unpublished) version when preview is on.
  • The _status: 'published' filter is added only when preview is off, so normal visitors never see drafts.

The convention is to put the fetch function below the component that uses it (function declarations are hoisted, so order doesn’t matter at runtime).

import { draftMode } from 'next/headers';
import { getPayload } from '@/lib/payload/local-api';
export default async function HomePage() {
const page = await getHomepage();
// ...render
}
async function getHomepage() {
const payload = await getPayload();
const { isEnabled } = await draftMode();
const { docs } = await payload.find({
collection: 'pages',
depth: 2,
limit: 1,
draft: isEnabled,
where: {
and: [
{ type: { equals: 'homepage' } },
...(isEnabled ? [] : [{ _status: { equals: 'published' } }]),
],
},
});
return docs.at(0) ?? null;
}

For [slug] routes, add a slug equals condition to the same and array. Passing depth as a parameter lets the page use one depth (2, for rendering) and generateMetadata use another (1, for meta.image):

async function getPage(slug: string, depth: number) {
const payload = await getPayload();
const { isEnabled } = await draftMode();
const { docs } = await payload.find({
collection: 'pages',
depth,
limit: 1,
draft: isEnabled,
where: {
and: [
{ slug: { equals: slug } },
...(isEnabled ? [] : [{ _status: { equals: 'published' } }]),
],
},
});
return docs.at(0) ?? null;
}

Globals use findGlobal. The header and footer read draftMode() the same way so preview shows draft global content:

async function getSiteHeader() {
const payload = await getPayload();
const { isEnabled } = await draftMode();
return payload.findGlobal({
slug: 'site-header',
depth: 2,
draft: isEnabled,
});
}

generateStaticParams and sitemap.ts run at build time, where there is no request to read draft state from — calling draftMode() there throws. These fetch published content explicitly with draft: false and a _status: 'published' filter, and never read draftMode():

export async function generateStaticParams() {
const payload = await getPayload();
const { docs } = await payload.find({
collection: 'posts',
limit: 100,
draft: false,
where: { _status: { equals: 'published' } },
select: { slug: true },
});
return docs.map((doc) => ({ slug: doc.slug }));
}

Because pages are statically generated, content changes are pushed out with revalidatePath:

  • Collections call revalidateCollection(slug, doc.slug) in afterChange and afterDelete, which revalidates the document’s path.
  • Globals call revalidatePath('/', 'layout') in afterChange. Globals render in the root layout, so this refreshes every page.

So editing or deleting content in the admin regenerates the affected static pages on the next request. See the Collections guide for the hook setup.