Payload Local API: Query Your Data Without Fetch
When Payload runs inside your app, you can read from your database directly.
You don't have to fetch your own API. The Local API is how you get your content out of Payload when Payload is already running inside your app, with no fetch call, no base URL, and no API key.
To go through this post, you'll want a configured app with a collection that has something in it, so if you don't have that yet, start with the collections post and come back.
I'm on version 3.88.0 here, but Payload 4 is on the way as I write this, so expect an update shortly after that releases.
What is the Payload Local API?
The Local API runs the same operations as the REST and GraphQL APIs. Same finds, same creates, same updates. The difference is where it runs.
REST and GraphQL go out over HTTP. Your app makes a request, a server handles it, and the data comes back over the network.
The Local API skips all of that. It runs in Node, on your server, straight against your database. No HTTP request, no waiting on the network.
That's possible because Payload installs inside your Next.js app. Your page and your database are already in the same process, and the Local API is what that architecture buys you.
You'll use it in React Server Components, in seed scripts, in custom route handlers, and inside hooks.
Setting it up
Getting the Local API is three lines.
1import { getPayload } from 'payload'2import config from '@payload-config'34const payload = await getPayload({ config })
Those three lines work anywhere on the server. But you're about to write them in every server component, every route handler, and every script you make.
So let's not do that. I'll put them in one file at src/lib/payload.ts, with the same imports as before, and export a small helper that gives you the payload instance.
1// src/lib/payload.ts2import { getPayload } from 'payload'3import config from '@payload-config'45export const getPayloadClient = async () => await getPayload({ config })
Now anywhere I need Payload, I can just use this helper.
In Next.js dev mode this works with hot module replacement, or HMR, so when you change your config, the Local API picks it up without a restart.
Now let's discuss some common Payload operations.
Query a collection with find
find is the one you'll use most. Let's get some posts. I'll use the home route the blank template gives us, over in src/app/(frontend)/page.tsx. We grab the client from the helper we just made, call find, and tell it which collection we want.
1import { getPayloadClient } from '@/lib/payload'23export default async function HomePage() {4 const payload = await getPayloadClient()56 const posts = await payload.find({7 collection: 'posts',8 })910 // log it so we can see what comes back11 console.log(posts)1213 // we're not rendering anything yet14 return null15}
That gives you back every post, along with some pagination info.
Narrowing it down with where, limit, page and sort
Now let's narrow it down. where takes a query object, and this one only returns posts whose title contains the word "payload". limit and page handle pagination, and sort takes a field name, with a minus in front for descending.
1const posts = await payload.find({2 collection: 'posts',3 where: {4 title: { contains: 'payload' },5 },6 limit: 10,7 page: 1,8 sort: '-createdAt',9})
where has a lot more operators than contains. There's equals, in, greater_than, not_equals, and a handful more, plus and and or for combining them.
You can even query across a relationship using dot notation.
1where: {2 'author.name': { contains: 'nick' },3},
That finds posts whose author's name contains "nick", without you querying the authors collection first.
depth and select
There are a couple of key options you'll want to know about.
depth controls how far Payload populates your relationships. At depth zero, the author field comes back as just an ID. At depth one, you get the whole author document. At depth two, you'd see relationships within related documents returned as well.
Depth two is the default, so be sure to adjust this if you don't need deeply nested relationships returned on every request.
If you're seeing an ID where you expected an object, your depth is too low. If your response is enormous, it's too high.
select limits which fields come back at all.
1select: {2 title: true,3 author: true,4},
If your page only renders a title and an author name, there's no reason to pull the entire post body out of the database. select and depth together are how you stop over-fetching.
findByID: get one document by its ID
When you know exactly which document you want, use findByID.
1const post = await payload.findByID({2 collection: 'posts',3 id: '507f1f77bcf86cd799439011',4})
Collection and id are the only required options.
In a real app you're not hardcoding an ID, though. It comes from the route. I'll make a new dynamic route at src/app/(frontend)/posts/[id]/page.tsx. In a Next.js dynamic route, the id comes in through params, and we pass it straight into findByID.
1import { getPayloadClient } from '@/lib/payload'23export default async function PostPage({ params }) {4 const { id } = await params56 const payload = await getPayloadClient()78 const post = await payload.findByID({9 collection: 'posts',10 id,11 })1213 return <h1>{post.title}</h1>14}
Realistically you'd use a slug, or link to the document another way. This is simply a demonstration of how findByID is used.
One option to know about is disableErrors. By default, asking for a document that doesn't exist throws an error. Set disableErrors to true and you get null back instead, which is usually what you want on a page that should render a not-found state.
overrideAccess
Access control is off by default in the Local API.
In the collections post, we scoped posts so an author could only read their own. That rule does not run here. Every Local API call skips it.
The option responsible is overrideAccess, and it defaults to true. True means "override the access rules," so out of the box, every call is running as if it had full permission.
There's a good reason for that default. The Local API runs on your server, and a seed script or a hook often needs to do things no logged-in user is allowed to do.
But watch what happens when you forget. Say I'm building a dashboard where an author sees their own drafts. I write the obvious query.
1const posts = await payload.find({2 collection: 'posts',3})
That returns every post in the collection. Every author's drafts, on every author's dashboard.
No error. No warning. The page renders, it looks correct, and it is technically correct until you have another author.
The fix has two halves. First you need to know who's asking. Then you tell Payload to respect your rules.
Getting the current user with payload.auth
You get your current user by using payload.auth. I'll make the dashboard at src/app/(frontend)/dashboard/page.tsx. In Next.js, we pull the incoming headers in from next/headers and await them inside the component. Then we pass the headers to payload.auth, which reads the cookie and gives us back the user. After that it's the same find we wrote earlier, and we map over the results to render each title.
12import { headers as nextHeaders } from 'next/headers'3import { getPayloadClient } from '@/lib/payload'45export default async function DashboardPage() {6 const headers = await nextHeaders()78 const payload = await getPayloadClient()910 // reads the cookie and gives us back the user11 const { user } = await payload.auth({ headers })1213 const posts = await payload.find({14 collection: 'posts',15 })1617 return posts.docs.map((post) => <h2 key={post.id}>{post.title}</h2>)18}
payload.auth does a lot more than return a user. It also returns that user's permissions, and it's the front door to everything authentication related in Payload. I'll cover that in more detail separately.
For now we just want the user.
Passing the user and turning the override off
In the dashboard's find, we pass in the user we just got back and turn the override off, so your access rules actually run.
1const posts = await payload.find({2 collection: 'posts',3 user,4 overrideAccess: false,5})
Now the access rule from our collection runs, and each author only gets their own posts.
Generally, if a Local API call contains something only a specific user can access, pass the user and turn the override off.
Writing data, and transactions
Reading is most of what you'll do, but the Local API writes too. Create, update and delete all work the same way, with a different verb.
1// create2await payload.create({ collection: 'posts', data: { title: 'Hello' } })34// update using an id5await payload.update({ collection: 'posts', id, data: { title: 'Updated' } })67// delete using an id8await payload.delete({ collection: 'posts', id })
update and delete each have a second form. Instead of an id, pass a where query and they'll act on everything that matches. It's useful, but it's also as dangerous as it sounds, so be careful with it. You don't want to delete or update a bunch of content you didn't mean to.
For writes, it's best practice to pass req through your operations.
1await payload.create({ collection: 'posts', data, req })
That's what puts your operations inside a single database transaction. If you're on Postgres, or on MongoDB with a replica set, this is what stops a multi-step write from leaving half-written data behind when something fails partway through.
There are more operations than the ones we've used today, and we'll pick them up as I release more tutorials. These operations handle counting documents, working with globals, and authentication.
Conclusion
And that's all there is to it! You've got a page reading straight from Payload with no API call, you know how to narrow a query down with where, depth and select, and you know common gotchas with overrideAccess.
If you want the full walkthrough of everything Payload in order, my course Payload Essentials covers it.