Skip to main content

APIs — Local / REST / GraphQL three layers

Goals for this chapter

  • See the relationship between Local / REST / GraphQL in one diagram
  • Understand why "Local from Server Components, REST/GraphQL from external clients" is the right split
  • Know where payload-types.ts plugs in
  • Be able to write Where / depth / locale / pagination across all three APIs

How the three layers relate

The key points:

  • The Local API calls PayloadCMS directly inside the Node process — no HTTP hop
  • REST and GraphQL are wrappers around the Local API — they receive HTTP requests and execute the Local API internally
  • Hooks run the same way on every path. Access Control, however, differs by route: REST / GraphQL always apply it, while the Local API bypasses it by default (see The Local API and Access Control below)

So the three layers don't compete — they sort callers by where the call originates from.

Decision rules

CallerFirst choiceWhy
Next.js Server Components / Server ActionsLocal APIZero HTTP cost, no auth headers, automatic type inference
Next.js Client Components / SPA on another frameworkRESTLow learning curve, plain fetch
One round-trip with deep nestingGraphQLAvoids over/under-fetching, declarative
Mobile / third-party integrations (from outside)REST or GraphQLAuth + HTTP is the standard
Touching another Collection inside the CMS (Hooks etc.)Local API (req.payload)An HTTP hop inside the server is pointless

Local API

Getting an instance

In the Next.js co-located setup, fetch one with getPayload({ config: configPromise }) (Local API Overview, following the official website template pattern):

import { getPayload } from 'payload'
import configPromise from '@payload-config'

const payload = await getPayload({ config: configPromise })
const posts = await payload.find({
collection: 'posts',
where: { _status: { equals: 'published' } },
limit: 10,
depth: 2,
})

Main methods

MethodUse
payload.find({ collection, where, limit, page, sort, depth, locale })List
payload.findByID({ collection, id, depth, locale })Single
payload.count({ collection, where })Count
payload.create({ collection, data })Create
payload.update({ collection, id, data })Update
payload.delete({ collection, id })Delete
payload.findGlobal({ slug, depth, locale })Global read
payload.updateGlobal({ slug, data })Global write
payload.login({ collection, data: { email, password } })Programmatic login

The Local API and Access Control (overrideAccess)

The Local API differs from REST / GraphQL in one decisive way: Access Control is not applied by default. The Local API's overrideAccess option defaults to true, which skips every access function you wrote in chapter 04 and runs with admin-level privileges (Local API Overview).

The confusing part: passing user alone does not enforce Access Control:

// ❌ Access Control stays bypassed even though user is passed
const posts = await payload.find({
collection: 'posts',
user: currentUser,
// overrideAccess defaults to true, so this returns everything, admin-style
})

// ✅ overrideAccess: false is what actually applies the user's permissions
const posts = await payload.find({
collection: 'posts',
user: currentUser,
overrideAccess: false,
})

How to choose:

  • Keep overrideAccess: true (default): cron / migration / seed and other trusted server-side work that legitimately touches everything
  • overrideAccess: false + user: operations on behalf of an end user (Server Actions / custom endpoints / webhooks) — scope to that user's permissions
  • overrideAccess: false without user: scope to what an unauthenticated visitor may see. The "public site returns published only" case (the implementation example in chapter 06) is this pattern

Your chapter-04 access functions always run for the Admin UI / REST / GraphQL, but in the Local API they run only where you explicitly pass overrideAccess: false. If you assume "the access function will hide drafts" in the chapter-06 Server Component pattern, you will actually fetch everything.

req.payload inside Hooks

The Hook argument req carries a payload instance, so calls into other Collections from a Hook should use it. For write operations, also pass req as an option:

hooks: {
afterChange: [
async ({ doc, req }) => {
await req.payload.create({
collection: 'audit-logs',
data: { type: 'post.changed', postId: doc.id },
req, // join the same transaction
})
},
],
}

Passing req carries req.transactionID through, so this audit-logs write commits only if the whole original request succeeds (and rolls back with it on failure). One caveat: don't pass req to fire-and-forget calls you don't await — inheriting the transaction without awaiting can produce a success response for changes that were rolled back (Transactions (official docs)).

REST API

Base URL

According to the official docs (REST API Overview), the default base URL is /api, configurable via routes.api. Collections plug their slug into the URL.

MethodPathUse
GET/api/{collection}List
GET/api/{collection}/{id}Single
GET/api/{collection}/countCount
POST/api/{collection}Create
PATCH/api/{collection}/{id}Update
DELETE/api/{collection}/{id}Delete
GET/api/globals/{global}Global read
POST/api/globals/{global}Global write

Query parameters

ParameterRole
whereFilter (nested syntax)
depthRelation expansion depth
limit / pagePagination
sortSort key (-createdAt for descending)
locale / fallback-localeLocale selection
select / populateField selection
draftInclude drafts (with Versions + Drafts)

Writing Where

REST uses a nested query syntax.

# Published posts with "Next" in the title, newest 20
GET /api/posts?where[_status][equals]=published&where[title][contains]=Next&sort=-createdAt&limit=20
OperatorMeaning
equals / not_equalsEquality
in / not_inInclusion / exclusion
greater_than / greater_than_equal / less_than / less_than_equalComparisons
contains / likeSubstring match
existsField presence
nearGeo proximity (point type)

Auth endpoints

An auth: true Collection also grows:

POST /api/users/login
POST /api/users/logout
GET /api/users/me
POST /api/users/refresh-token
POST /api/users/forgot-password

fetch example

// From a client
const res = await fetch('/api/posts?where[_status][equals]=published&limit=10')
const json = await res.json()
console.log(json.docs) // array of documents

GraphQL API

Endpoint

According to the official docs (GraphQL Overview):

  • GraphQL: /api/graphql
  • Playground: /api/graphql-playground (on in dev by default, off in prod by default)
  • Both configurable via routes

Naming conventions

OperationQuery / mutation
SinglePost(id: "...") (singular)
ListPosts(where: {...}, limit: 10) (plural)
CountcountPosts(where: {...})
CreatecreatePost(data: {...})
UpdateupdatePost(id: "...", data: {...})
DeletedeletePost(id: "...")
LoginloginUser(email: "...", password: "...")

Type names are derived from the Collection slug (special characters and spaces are stripped). You can override singular / plural labels via labels.

Query example

query PublishedPosts {
Posts(
where: { _status: { equals: published } }
sort: "-createdAt"
limit: 10
) {
docs {
id
title
slug
author {
id
name
}
}
totalDocs
}
}

Disabling

// payload.config.ts
export default buildConfig({
// ...
graphQL: {
disable: true,
},
})

Projects that don't use GraphQL can remove it entirely with disable: true. To hide just the Playground in production, use disablePlaygroundInProduction: true.

Generated types (payload-types.ts)

Generating

According to the official docs (Generating Types):

# When the config lives under src/
PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types

The conventional pattern is a package.json script:

{
"scripts": {
"generate:types": "PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types"
}
}

Watch-mode regeneration during dev is not documented, so the default flow is edit config → run yarn generate:types manually (or via a script).

Emitted types

  • An interface per Collection (Post, User, Media, …)
  • An interface per Global (Header, SiteSettings, …)
  • Setting interfaceName on Block / Array / Group fields emits a reusable top-level interface as well

Using them on the frontend

import type { Post, User } from '@/payload-types'

const posts: Post[] = await payload.find({ collection: 'posts' }).then((r) => r.docs)

Both Local API return values and REST responses fit this type, so the whole frontend stays type-safe. With Next.js co-location you import payload-types.ts from inside the project — no separate package to ship around.

The same query, three APIs

"Ten newest published posts" expressed in each API:

// Local API (inside a Server Component, etc.)
const { docs } = await payload.find({
collection: 'posts',
where: { _status: { equals: 'published' } },
sort: '-createdAt',
limit: 10,
depth: 2,
})
# REST
GET /api/posts?where[_status][equals]=published&sort=-createdAt&limit=10&depth=2
# GraphQL
{
Posts(
where: { _status: { equals: published } }
sort: "-createdAt"
limit: 10
) {
docs { id title }
}
}

REST and GraphQL return only authorized data, filtered through Access Control. The Local API behaves the same only when you explicitly pass overrideAccess: false (see above).

Implementation example — custom REST endpoints (endpoints)

When the auto-generated REST endpoints aren't enough, you can add your own handlers to the endpoints array on a Collection (or on the Config). The official example: check auth → read the request body → update a doc via the Local API → return JSON (REST API Overview).

// collections/Tracking.ts
import type { CollectionConfig } from 'payload'

export const Tracking: CollectionConfig = {
slug: 'tracking',
endpoints: [
{
path: '/:id/tracking',
method: 'post',
handler: async (req) => {
if (!req.user) {
return Response.json({ error: 'forbidden' }, { status: 403 })
}

const data = await req.json()

await req.payload.update({
collection: 'tracking',
id: req.routeParams.id as string,
data,
})

return Response.json({ message: 'successfully updated tracking info' })
},
},
],
fields: [/* ... */],
}

Notes:

  • :id in path is read via req.routeParams.id
  • method is lowercase ('get' / 'post' / …)
  • Reject unauthenticated calls with Response.json({ error }, { status: 403 })
  • Return values use Response.json(...) (same shape as Next.js Route Handlers)
  • req.payload lets the handler call the Local API (Local API section) inline

The route is published at POST /api/tracking/:id/tracking (the Collection slug becomes the prefix).

Source: REST API Overview (official docs)

Wrap-up

  • The three layers don't compete — they sort by where the caller lives
  • Server Components / Server Actions / Hooks → Local API (zero HTTP, the same instance via req.payload)
  • The Local API bypasses Access Control by default (overrideAccess: true). Scope by permissions with overrideAccess: false + user; on writes inside Hooks, pass req to share the transaction
  • External clients → REST (simple) or GraphQL (declarative, deep nesting)
  • payload generate:types emits payload-types.ts — type safety end-to-end
  • The Where operators are the same set (equals / contains / greater_than …) across all three; only the syntax differs
  • For endpoints the auto-generation doesn't cover, drop a custom handler into the endpoints array (use req.user for auth, Response.json for the reply)

Next

Next, 06 Next.js Integration — A deep dive on co-location is the headline chapter of the series. We unpack the app/(payload)/ route group, getPayload() usage, Lexical rendering, Live Preview, and Draft Preview across 12 sections.

References