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.tsplugs 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
| Caller | First choice | Why |
|---|---|---|
| Next.js Server Components / Server Actions | Local API | Zero HTTP cost, no auth headers, automatic type inference |
| Next.js Client Components / SPA on another framework | REST | Low learning curve, plain fetch |
| One round-trip with deep nesting | GraphQL | Avoids over/under-fetching, declarative |
| Mobile / third-party integrations (from outside) | REST or GraphQL | Auth + 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
| Method | Use |
|---|---|
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 permissionsoverrideAccess: falsewithoutuser: 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.
| Method | Path | Use |
|---|---|---|
GET | /api/{collection} | List |
GET | /api/{collection}/{id} | Single |
GET | /api/{collection}/count | Count |
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
| Parameter | Role |
|---|---|
where | Filter (nested syntax) |
depth | Relation expansion depth |
limit / page | Pagination |
sort | Sort key (-createdAt for descending) |
locale / fallback-locale | Locale selection |
select / populate | Field selection |
draft | Include 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
| Operator | Meaning |
|---|---|
equals / not_equals | Equality |
in / not_in | Inclusion / exclusion |
greater_than / greater_than_equal / less_than / less_than_equal | Comparisons |
contains / like | Substring match |
exists | Field presence |
near | Geo 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
| Operation | Query / mutation |
|---|---|
| Single | Post(id: "...") (singular) |
| List | Posts(where: {...}, limit: 10) (plural) |
| Count | countPosts(where: {...}) |
| Create | createPost(data: {...}) |
| Update | updatePost(id: "...", data: {...}) |
| Delete | deletePost(id: "...") |
| Login | loginUser(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
interfaceNameon 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:
:idinpathis read viareq.routeParams.idmethodis 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.payloadlets 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).
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 withoverrideAccess: false+user; on writes inside Hooks, passreqto share the transaction - External clients → REST (simple) or GraphQL (declarative, deep nesting)
payload generate:typesemits 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
endpointsarray (usereq.userfor auth,Response.jsonfor 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.