How to Use Payload with Astro: Complete Guide
Step-by-step guide to integrating Payload with your Astro website.
Payload is an open-source TypeScript headless CMS and application framework that gives you full control over your backend. Unlike hosted CMS platforms, Payload runs on your own infrastructure, which means zero vendor lock-in and complete data ownership. It generates both REST and GraphQL APIs from your config, comes with a powerful admin panel, and supports authentication, access control, and file uploads out of the box.
For Astro developers, Payload is the option when you want a CMS you fully own and can extend without limits. The trade-off is more setup and infrastructure management, but the payoff is a backend that scales with you.
Prerequisites
- Node.js 22.12.0 or later. Astro 6 dropped Node 18 and 20, and the
ky2.x HTTP client also requires Node 22. - An Astro project (
npm create astro@latest) - A running Payload instance (self-hosted or Payload Cloud). Payload 3 runs inside a Next.js app and serves its REST API under
/api. - MongoDB or PostgreSQL for Payload's database
- Basic familiarity with TypeScript
Verified package versions as of 2026-05-29: Astro 6.4.2, Payload (and create-payload-app) 3.85.0, ky 2.0.2.
Installation
On the Astro side, you just need a way to call Payload's REST API. No special client library is required:
npm install ky@2.0.2
You can also use the native fetch API, which is what the official Astro CMS guide uses. Ky is just a convenience wrapper that handles JSON parsing and errors more cleanly. If you stay on the native fetch, you can skip this dependency entirely.
If your Payload instance runs separately, you only need the HTTP client. If you want to scaffold a fresh Payload backend, run the official starter (the -t blank flag gives you an empty project and the wizard prompts you for the database adapter):
npx create-payload-app@latest -t blank
The default create-payload-app template is built on Next.js. Run it as its own service. A fresh Payload 3 app defaults to port 3000 and Astro's dev server defaults to port 4321, so they do not clash out of the box. If you change either port, keep PAYLOAD_API_URL in your Astro .env pointed at wherever Payload actually runs.
Configuration
Create a client helper for your Payload API:
// src/lib/payload.ts
import ky from "ky";
const payload = ky.create({
prefixUrl: import.meta.env.PAYLOAD_API_URL,
headers: {
...(import.meta.env.PAYLOAD_API_KEY && {
Authorization: `users API-Key ${import.meta.env.PAYLOAD_API_KEY}`,
}),
},
});
export async function getCollection<T>(
collection: string,
params: Record<string, string> = {}
): Promise<{ docs: T[]; totalDocs: number }> {
const searchParams = new URLSearchParams(params);
return payload.get(`api/${collection}?${searchParams}`).json();
}
export async function getDocument<T>(
collection: string,
idOrSlug: string,
field = "slug"
): Promise<T> {
const result = await payload
.get(`api/${collection}?where[${field}][equals]=${idOrSlug}&limit=1`)
.json<{ docs: T[] }>();
return result.docs[0];
}
Add your Payload connection details to .env:
PAYLOAD_API_URL=http://localhost:3000
PAYLOAD_API_KEY=your-api-key-here
On the Payload side, define your collections. Here is a typical blog post collection config. Note that we do not add a custom status field. Payload's versions and drafts feature manages publish state for you in an internal _status field whose values are draft and published.
// src/collections/Posts.ts
import type { CollectionConfig } from "payload";
export const Posts: CollectionConfig = {
slug: "posts",
admin: {
useAsTitle: "title",
},
versions: {
drafts: true,
},
access: {
// Only return published documents to unauthenticated readers.
read: ({ req: { user } }) => {
if (user) return true;
return { _status: { equals: "published" } };
},
},
fields: [
{ name: "title", type: "text", required: true },
{ name: "slug", type: "text", required: true, unique: true },
{ name: "excerpt", type: "textarea" },
{ name: "content", type: "richText" },
{ name: "featuredImage", type: "upload", relationTo: "media" },
{ name: "publishedAt", type: "date" },
{ name: "tags", type: "array", fields: [{ name: "tag", type: "text" }] },
],
};
The read access rule above matters: per Payload's docs, the draft argument on its own does not stop documents with _status: 'draft' from being returned by the API. You must add an access control rule to keep drafts private.
Basic Usage
Fetch posts from Payload and render them in Astro:
---
// src/pages/blog/index.astro
import { getCollection } from "../../lib/payload";
import BaseLayout from "../../layouts/BaseLayout.astro";
interface Post {
id: string;
title: string;
slug: string;
excerpt: string;
featuredImage?: { url: string };
publishedAt: string;
// `content` is Lexical JSON (an object), not an HTML string.
content?: unknown;
}
const { docs: posts } = await getCollection<Post>("posts", {
"where[_status][equals]": "published",
sort: "-publishedAt",
limit: "50",
});
---
<BaseLayout title="Blog">
<h1>Blog</h1>
{posts.map((post) => (
<article>
<a href={`/blog/${post.slug}`}>
{post.featuredImage && (
<img src={post.featuredImage.url} alt={post.title} loading="lazy" />
)}
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</a>
</article>
))}
</BaseLayout>
Dynamic post pages. The content richText field comes back as Lexical JSON, not HTML, so you convert it with Payload's official converter before rendering:
---
// src/pages/blog/[slug].astro
import { getCollection, getDocument } from "../../lib/payload";
import { convertLexicalToHTML } from "@payloadcms/richtext-lexical/html";
import BaseLayout from "../../layouts/BaseLayout.astro";
export async function getStaticPaths() {
const { docs: posts } = await getCollection("posts", {
"where[_status][equals]": "published",
limit: "1000",
});
return posts.map((post) => ({
params: { slug: post.slug },
}));
}
const { slug } = Astro.params;
const post = await getDocument("posts", slug);
// `content` is Lexical JSON; turn it into an HTML string.
const contentHtml = convertLexicalToHTML({ data: post.content });
---
<BaseLayout title={post.title}>
<article>
<h1>{post.title}</h1>
<time>{new Date(post.publishedAt).toLocaleDateString()}</time>
{post.featuredImage && (
<img src={post.featuredImage.url} alt={post.title} />
)}
<div set:html={contentHtml} />
</article>
</BaseLayout>
If your rich text references uploads or relationship links that you want fully populated, swap in the async converter instead: import { convertLexicalToHTMLAsync } from "@payloadcms/richtext-lexical/html-async", then await convertLexicalToHTMLAsync({ data: post.content, populate: ... }).
A Note on Astro 6 Rendering
Everything above runs at build time, which is the right default. In Astro 6, static is the default output and your entire site is prerendered unless you opt out. The output: 'hybrid' option no longer exists. It was removed in Astro 5, and static remains the default in Astro 6. If you need a page to render on demand (for example, live preview of Payload drafts), install an adapter and set export const prerender = false on that page only, or set output: 'server' in astro.config.mjs to flip the whole site to on-demand rendering. The rest of your blog can stay fully static.
Production Tips
Use Payload's access control. Define granular access rules per collection and field. This is especially important if you expose the API publicly. Lock down create/update/delete operations and only allow read access for your frontend API key.
Enable Payload's draft system. Set
versions: { drafts: true }on your collections, as shown above. This lets editors work on draft content that only publishes when approved, which is critical for production content workflows. Pair it with a read access rule so unauthenticated requests only see_status: 'published'documents.Set up a webhook for static rebuilds. Payload supports an
afterChangecollection hook. Use it to trigger a rebuild on your hosting platform whenever a post is published or updated, so your static Astro output stays in sync with the CMS.Use Payload's built-in image resizing. Configure image sizes in your media collection to generate thumbnails and responsive variants automatically. This saves bandwidth and speeds up page loads.
Deploy Payload separately. Run Payload on its own server or container, separate from your Astro frontend. This lets each scale independently. Payload works well on Railway, Render, or your own Docker setup.
Alternatives to Consider
- Strapi if you want a similar self-hosted CMS with a more visual admin panel builder and a larger plugin ecosystem.
- Directus if you prefer a CMS that wraps any existing SQL database with an instant REST and GraphQL API.
- Sanity if you want a hosted solution with real-time collaboration and do not want to manage CMS infrastructure.
Common Errors and Fixes
set:html renders [object Object] or empty content. The content field is Lexical JSON, not HTML. You cannot drop it straight into set:html. Run it through convertLexicalToHTML({ data: post.content }) from @payloadcms/richtext-lexical/html first, as shown above.
Draft posts leak to anonymous visitors. Per Payload's drafts docs, the draft argument alone does not stop _status: 'draft' documents from being returned by the API. Add a read access control rule that filters on _status for unauthenticated users, and query published content with where[_status][equals]=published.
Filtering on a status field returns nothing. When versions.drafts is enabled, the publish state lives in Payload's internal _status field (values draft and published), not a custom status field you defined. Query where[_status][equals]=published, with the leading underscore.
401 Unauthorized on REST calls. API key auth uses a specific, case-sensitive Authorization header in the form <collection-slug> API-Key <key>, for example users API-Key abc123. Enable it with auth: { useAPIKey: true } on the collection that holds the key. A missing collection slug or the wrong casing returns 401.
Node version error on npm install or build. Astro 6 requires Node 22.12.0 or later, and ky 2.x requires Node 22. On Node 18 or 20 the install or build fails. Upgrade Node before anything else.
output: 'hybrid' is not a valid config value. Hybrid output was removed in Astro 5. Static is the default in Astro 6. Use export const prerender = false on individual on-demand pages, or output: 'server' for a fully on-demand site, and add an adapter for either.
Both dev servers fight over the same port. A Payload 3 app defaults to port 3000 and Astro to 4321, so they coexist by default. If you run something else on 3000, change the Payload port in its Next.js dev script (for example next dev -p 3001) and update PAYLOAD_API_URL in your Astro .env to match.
Official Docs and Examples
- Official Astro CMS guide for Payload: https://docs.astro.build/en/guides/cms/payload/
- Payload REST API reference: https://payloadcms.com/docs/rest-api/overview
- Payload query operators: https://payloadcms.com/docs/queries/overview
- Payload drafts and versions: https://payloadcms.com/docs/versions/drafts
- Payload API key authentication: https://payloadcms.com/docs/authentication/api-keys
- Payload Lexical to HTML conversion: https://payloadcms.com/docs/rich-text/converting-html
- Astro on-demand rendering and adapters: https://docs.astro.build/en/guides/on-demand-rendering/
- Example repo (simple blog template, TypeScript): https://github.com/Lambdo-Labs/payloadcms-astro-template
- Example repo (full-featured site: SEO, sitemap, i18n, live preview): https://github.com/jhb-software/payload-astro-website-template
Wrapping Up
Payload is the CMS for developers who want full control. The TypeScript-first approach means your content schema, access control, and custom logic are all type-safe and versioned in code. Paired with Astro, you get a static frontend that is fast and cheap to host, backed by a CMS that can grow into a full application framework. The learning curve is steeper than hosted alternatives, but if you need a CMS that bends to your requirements instead of the other way around, Payload delivers.
Sources
Checked on 2026-05-29.
- Astro npm package (version 6.4.2, Node engine): https://registry.npmjs.org/astro/latest
- Payload npm package (version 3.85.0): https://registry.npmjs.org/payload/latest
- create-payload-app npm package (version 3.85.0): https://registry.npmjs.org/create-payload-app/latest
- ky npm package (version 2.0.2, Node 22 engine): https://registry.npmjs.org/ky/latest
- Official Astro CMS guide for Payload: https://docs.astro.build/en/guides/cms/payload/
- Astro on-demand rendering, static default and adapters: https://docs.astro.build/en/guides/on-demand-rendering/
- Astro 6.0 release notes (Node 22.12.0 requirement): https://astro.build/blog/astro-6/
- Payload REST API overview (default
/apibase, query params): https://payloadcms.com/docs/rest-api/overview - Payload querying documents (where operators): https://payloadcms.com/docs/queries/overview
- Payload drafts and versions (
_statusfield): https://payloadcms.com/docs/versions/drafts - Payload API key authentication (Authorization header format): https://payloadcms.com/docs/authentication/api-keys
- Payload converting Lexical rich text to HTML: https://payloadcms.com/docs/rich-text/converting-html
- Payload installation and create-payload-app templates: https://payloadcms.com/docs/getting-started/installation
- Example repo, Payload + Astro blog template: https://github.com/Lambdo-Labs/payloadcms-astro-template
- Example repo, full Payload + Astro website template: https://github.com/jhb-software/payload-astro-website-template
Related Articles
How to Use Algolia with Astro: Complete Guide
Step-by-step guide to integrating Algolia with your Astro website.
How to Integrate Auth0 with Astro: Complete Guide
Step-by-step guide to integrating Auth0 with your Astro website. Setup, configuration, and best practices.
How to Use AWS Amplify with Astro: Complete Guide
Step-by-step guide to integrating AWS Amplify with your Astro website.