Connect Payload CMS to an Astro template
The Astro edition is a static site: every page is plain HTML, built ahead of time. Payload fits it as a separate app — the admin and an API — that the site reads while it builds. This guide sets up both for the Nitip Astro edition: at the end, editors manage services, projects, posts, jobs, team and testimonials in Payload, and the site rebuilds from it when they save.
We followed every step below on a fresh copy of Nitip 1.0.0 with Payload 3.90.2 and Astro 7.3, on a local machine. Every
other Framebrick Astro template works the same way: its collections come from the same kind of loaders in
src/content.config.ts, so only the collections and fields differ — its Using a headless CMS page lists them.
How it fits together
Section titled “How it fits together”my-site/├── nitip-astro/ the website: this template└── nitip-cms/ Payload: the admin at /admin and the API- The site’s collections load from Payload’s API instead of the JSON files. The schemas stay, so every entry is checked before a page uses it, and the pages and components don’t change.
- When an editor saves, Payload calls one URL. While you work, that’s the Astro dev server, which reloads the content. On a host, it’s the deploy hook that rebuilds the site.
- Images are downloaded from Payload during the build and optimized like the template’s own, so visitors never load anything from the CMS.
Before you start
Section titled “Before you start”- The Nitip Astro edition, unzipped into a folder of its own (
my-site/nitip-astro), withnpm installdone andnpm run devworking (Install and run). - Node.js 22.12 or newer.
- About 45 minutes.
1. Create the CMS
Section titled “1. Create the CMS”In my-site (the folder that holds nitip-astro), create a Payload project with its blank starter:
npx create-payload-app@3.90.2 -n nitip-cms -t blank --db sqlite --db-connection-string "file:./nitip.db" --use-npm --no-agentThat makes nitip-cms with the admin, the API, a login collection (Users) and an image collection (Media). The
database is SQLite — a single file, nothing to set up; for hosting you’ll swap it for Postgres
(Going live). The .env it writes already has a random PAYLOAD_SECRET.
create-payload-app starts a Git repository but doesn’t ignore the database file. Add it to nitip-cms/.gitignore:
# database*.db2. Add the site’s collections
Section titled “2. Add the site’s collections”Each collection mirrors a schema in nitip-astro/src/content/schemas.ts, field for field. Create
nitip-cms/src/collections/Content.ts:
import type { Block, CollectionAfterChangeHook, CollectionAfterDeleteHook, CollectionConfig, Field, PayloadRequest,} from 'payload'
/* * The site's collections. Each one mirrors a schema in the Astro project's src/content/schemas.ts, so its loader * (src/lib/payload.ts there) only renames a few fields. */
/* Saving publishes: PUBLISH_HOOK_URL is your host's deploy hook in production, or the Astro dev server's /_refresh while you work locally. The seed script turns it off. */const publish = async (req: PayloadRequest) => { const url = process.env.PUBLISH_HOOK_URL if (!url || req.context.disablePublish) return try { await fetch(url, { method: 'POST' }) } catch (error) { req.payload.logger.error({ err: error, msg: `Couldn't call PUBLISH_HOOK_URL (${url})` }) }}const afterChange: CollectionAfterChangeHook = async ({ doc, req }) => { await publish(req) return doc}const afterDelete: CollectionAfterDeleteHook = async ({ doc, req }) => { await publish(req) return doc}
/* Shared fields */const slug: Field = { name: 'slug', type: 'text', required: true, unique: true, index: true, admin: { position: 'sidebar', description: 'The page address: lowercase words joined by hyphens.', },}const image = (name: string, label?: string): Field => ({ name, label, type: 'upload', relationTo: 'media', required: true,})const imagePair = (name: string): Field => ({ name, type: 'upload', relationTo: 'media', hasMany: true, minRows: 2, maxRows: 2, required: true,})/* Text that may start with a bold phrase ("Instant Traffic: Drive qualified visitors…"). */const highlighted = (name: string): Field => ({ name, type: 'array', fields: [ { name: 'highlight', type: 'text', admin: { description: 'Bold phrase at the start (optional).' }, }, { name: 'text', type: 'textarea' }, ],})const lines = (name: string): Field => ({ name, type: 'array', fields: [{ name: 'text', type: 'textarea', required: true }],})
/* Post body: the blocks the template renders (schemas.ts → postBlockSchema), two list levels deep. */const heading: Block = { slug: 'heading', fields: [ { name: 'bold', type: 'text' }, { name: 'text', type: 'text' }, ],}const paragraph: Block = { slug: 'paragraph', fields: [ { name: 'highlight', type: 'text' }, { name: 'text', type: 'textarea' }, ],}const listFields = (inner: Block[]): Field[] => [ { name: 'ordered', type: 'checkbox', label: 'Numbered list' }, { name: 'items', type: 'array', fields: [{ name: 'content', type: 'blocks', blocks: inner }] },]const subList: Block = { slug: 'subList', labels: { singular: 'List', plural: 'Lists' }, fields: listFields([paragraph, heading]),}const list: Block = { slug: 'list', fields: listFields([paragraph, heading, subList]) }
/* Content collections are public to read (the site shows them anyway), editable by signed-in users, sorted by drag and drop in the list, and published on save. */const collection = ( config: Omit<CollectionConfig, 'access' | 'hooks' | 'orderable'>,): CollectionConfig => ({ ...config, access: { read: () => true }, hooks: { afterChange: [afterChange], afterDelete: [afterDelete] }, orderable: true,})
export const Services = collection({ slug: 'services', admin: { useAsTitle: 'title', defaultColumns: ['title', 'slug'] }, fields: [ slug, { name: 'title', type: 'text', required: true }, { name: 'number', type: 'text', admin: { description: 'Shown before the title, e.g. 01.' } }, imagePair('images'), { name: 'description', type: 'textarea' }, { name: 'tools', type: 'array', fields: [ { name: 'label', type: 'text' }, { name: 'href', type: 'text' }, ], }, highlighted('offer'), highlighted('benefits'), { name: 'plans', type: 'array', fields: [ { name: 'key', type: 'text', required: true, admin: { description: 'Short id, e.g. weekly.' }, }, { name: 'label', type: 'text' }, { name: 'price', type: 'text' }, { name: 'note', type: 'text' }, lines('included'), ], }, ],})
export const Projects = collection({ slug: 'projects', admin: { useAsTitle: 'title', defaultColumns: ['title', 'year', 'slug'] }, fields: [ slug, { name: 'title', type: 'text', required: true }, { name: 'year', type: 'text' }, image('image', 'Card image'), { name: 'services', type: 'relationship', relationTo: 'services', hasMany: true }, { name: 'summary', type: 'textarea' }, { name: 'industry', type: 'text' }, { name: 'overview', type: 'group', fields: [{ name: 'text', type: 'textarea' }, image('image')], }, { name: 'process', type: 'group', fields: [{ name: 'text', type: 'textarea' }, imagePair('images')], }, { name: 'result', type: 'group', fields: [highlighted('items'), image('image')] }, ],})
export const Posts = collection({ slug: 'posts', admin: { useAsTitle: 'title', defaultColumns: ['title', 'category', 'date'] }, fields: [ slug, { name: 'title', type: 'text', required: true }, { name: 'excerpt', type: 'textarea' }, image('image'), { name: 'category', type: 'text' }, /* dayOnly saves noon UTC, so the day stays the same in every time zone. */ { name: 'date', type: 'date', required: true, admin: { date: { pickerAppearance: 'dayOnly', displayFormat: 'MMM d, yyyy' } }, }, { name: 'body', type: 'blocks', blocks: [heading, paragraph, list] }, ],})
export const Careers = collection({ slug: 'careers', admin: { useAsTitle: 'title' }, fields: [ slug, { name: 'title', type: 'text', required: true }, { name: 'summary', type: 'textarea' }, lines('responsibilities'), lines('requirements'), lines('benefits'), ],})
export const Team = collection({ slug: 'team', labels: { singular: 'Team member', plural: 'Team' }, admin: { useAsTitle: 'name' }, fields: [ { name: 'name', type: 'text', required: true }, { name: 'role', type: 'text' }, { name: 'email', type: 'email' }, image('photo'), ],})
export const Testimonials = collection({ slug: 'testimonials', admin: { useAsTitle: 'name' }, fields: [ { name: 'quote', type: 'textarea', required: true }, { name: 'name', type: 'text', required: true }, { name: 'company', type: 'text' }, { name: 'service', type: 'text', admin: { description: 'The service the client used, shown under the name.' }, }, image('photo'), image('logo'), ],})A few choices worth knowing:
- Order.
orderable: truegives each list drag handles in the admin, and the site shows entries in that order. New entries go to the end; drag them where you want them. - Publishing. The
afterChangeandafterDeletehooks callPUBLISH_HOOK_URL(step 6). - Post bodies are blocks — Heading, Paragraph and List — the same blocks the template renders, with one list level inside another, which covers every post in the demo.
- Pricing plans have a
keyfield (weekly,monthly…) because Payload reservesidfor its own row ids. - Dates use the day-only picker, which saves noon UTC — the day stays the same in every time zone.
The template’s images are decorative (they have empty alt text), so make alt optional in
nitip-cms/src/collections/Media.ts:
{ name: 'alt', type: 'text', required: true, },Then register the collections in nitip-cms/src/payload.config.ts:
import { Users } from './collections/Users'import { Media } from './collections/Media'import { Careers, Posts, Projects, Services, Team, Testimonials } from './collections/Content'
// …
export default buildConfig({ // … collections: [Users, Media], collections: [Users, Media, Services, Projects, Posts, Careers, Team, Testimonials],And let Payload update its generated files — the admin’s component map and the TypeScript types:
cd nitip-cmsnpm run generate:importmapnpm run generate:types3. Import the demo content
Section titled “3. Import the demo content”This script copies the JSON collections and images from nitip-astro into Payload, once. Create
nitip-cms/src/seed.ts:
/* * Copies the demo content of the Astro site into Payload, once: * npx payload run src/seed.ts * Reads the JSON in <site>/src/content/collections and uploads the images from <site>/src/assets/images. Run it on an * empty database; it stops if services already exist. */import fs from 'fs'import path from 'path'import { getPayload } from 'payload'
import config from './payload.config'
/* The Astro project, in the folder next to this one. */const site = path.resolve('../nitip-astro')
/* The shapes of the JSON files (the Astro project's src/content/schemas.ts). */type Highlighted = { highlight?: string; text: string }type PostBlock = | { type: 'heading'; text: string; bold?: string } | { type: 'paragraph'; highlight?: string; text: string } | { type: 'list'; ordered?: boolean; items: PostBlock[][] }type Service = { slug: string number: string title: string images: [string, string] description: string tools: { label: string; href: string }[] offer: Highlighted[] benefits: Highlighted[] plans: { id: string; label: string; price: string; note: string; included: string[] }[]}type Project = { slug: string title: string year: string image: string services: { label: string; slug: string }[] summary: string industry: string overview: { text: string; image: string } process: { text: string; images: [string, string] } result: { items: Highlighted[]; image: string }}type Post = { slug: string title: string excerpt: string image: string category: string date: string body: PostBlock[]}type Career = { slug: string title: string summary: string responsibilities: string[] requirements: string[] benefits: string[]}type TeamMember = { name: string; role: string; email: string; photo: string }type Testimonial = { quote: string name: string company: string service: string photo: string logo: string}
const read = <T>(name: string): T[] => JSON.parse(fs.readFileSync(path.join(site, 'src/content/collections', name), 'utf8'))const services = read<Service>('services.json')const projects = read<Project>('projects.json')const posts = read<Post>('posts.json')const careers = read<Career>('careers.json')const team = read<TeamMember>('team.json')const testimonials = read<Testimonial>('testimonials.json')
const payload = await getPayload({ config })/* The site is built after seeding, so there is nothing to publish yet. */const context = { disablePublish: true }
if ((await payload.count({ collection: 'services' })).totalDocs > 0) { payload.logger.info( 'Services already exist — the database is seeded. Delete nitip.db and the media folder to start over.', ) process.exit(0)}
/* "/images/blog-1.jpg" → the id of its Media document, uploading each file once. */const uploaded = new Map<string, number>()async function media(src: string) { if (!uploaded.has(src)) { const filePath = path.join(site, 'src/assets', src) const doc = await payload.create({ collection: 'media', data: { alt: '' }, filePath, context }) uploaded.set(src, doc.id) } return uploaded.get(src)!}const mediaPair = async ([a, b]: [string, string]) => [await media(a), await media(b)]const lines = (items: string[]) => items.map((text) => ({ text }))
/* A post body block → the Payload block, with lists inside lists becoming "subList". */function block(item: PostBlock, nested = false): Record<string, unknown> { if (item.type === 'heading') return { blockType: 'heading', text: item.text, bold: item.bold } if (item.type === 'paragraph') return { blockType: 'paragraph', highlight: item.highlight, text: item.text } return { blockType: nested ? 'subList' : 'list', ordered: item.ordered ?? false, items: item.items.map((content) => ({ content: content.map((inner) => block(inner, true)) })), }}
const serviceIds = new Map<string, number>()for (const service of services) { const doc = await payload.create({ collection: 'services', context, data: { ...service, images: await mediaPair(service.images), plans: service.plans.map(({ id, included, ...plan }) => ({ ...plan, key: id, included: lines(included), })), }, }) serviceIds.set(service.slug, doc.id)}
for (const project of projects) { await payload.create({ collection: 'projects', context, data: { ...project, image: await media(project.image), services: project.services.map((ref) => serviceIds.get(ref.slug)!), overview: { text: project.overview.text, image: await media(project.overview.image) }, process: { text: project.process.text, images: await mediaPair(project.process.images) }, result: { items: project.result.items, image: await media(project.result.image) }, }, })}
for (const post of posts) { await payload.create({ collection: 'posts', context, data: { ...post, image: await media(post.image), body: post.body.map((item) => block(item)) as never, }, })}
for (const career of careers) { await payload.create({ collection: 'careers', context, data: { ...career, responsibilities: lines(career.responsibilities), requirements: lines(career.requirements), benefits: lines(career.benefits), }, })}
for (const member of team) { await payload.create({ collection: 'team', context, data: { ...member, photo: await media(member.photo) }, })}
for (const testimonial of testimonials) { await payload.create({ collection: 'testimonials', context, data: { ...testimonial, photo: await media(testimonial.photo), logo: await media(testimonial.logo), }, })}
payload.logger.info( `Seeded ${services.length} services, ${projects.length} projects, ${posts.length} posts, ${careers.length} careers, ${team.length} team members, ${testimonials.length} testimonials and ${uploaded.size} images.`,)process.exit(0)Run it in nitip-cms:
npx payload run src/seed.tsIt creates the database (nitip.db), uploads the images to media/ and ends with:
Seeded 8 services, 8 projects, 12 posts, 12 careers, 6 team members, 3 testimonials and 80 images.4. Start the CMS
Section titled “4. Start the CMS”npm run devOpen localhost:3000/admin and create the first user — your admin account. Leave the CMS running: the site reads it from now on.
5. Load the site’s collections from Payload
Section titled “5. Load the site’s collections from Payload”Everything in this step happens in nitip-astro.
.env — tell the site where Payload runs (create the file if you haven’t for the forms):
# Where Payload runs. On a host: the CMS's public address, e.g. https://cms.yourcompany.comCMS_URL=http://localhost:3000The loaders. Create nitip-astro/src/lib/payload.ts. Each loader fetches one collection and maps Payload’s fields
to the template’s:
/* * Collections from Payload, the CMS next to this project. Each loader fetches one collection through Payload's REST * API when the site builds (and when the dev server refreshes, see src/integrations/cms-refresh.ts) and maps the * documents to the schemas in src/content/schemas.ts, which check every entry before a page uses it. */import type { PostBlock } from "../content/schemas";
/** Where Payload runs: CMS_URL in .env. */export const cms = import.meta.env.CMS_URL ?? "http://localhost:3000";
type Doc = Record<string, any>;
/** A loader for one collection, in the order editors set in the admin (`position`, as with the JSON files). */export function payload(collection: string, map: (doc: Doc) => Doc, key = "slug") { return async () => { const response = await fetch(`${cms}/api/${collection}?pagination=false&depth=1&sort=_order`).catch(() => undefined); if (!response?.ok) throw new Error(`Couldn't load ${collection} from Payload at ${cms}. Is the CMS running?`); const { docs } = (await response.json()) as { docs: Doc[] }; return docs.map((doc, position) => { const data = map(doc); return { id: String(data[key]), position, ...data }; }); };}
/* Uploads come back as Media documents with a URL on the CMS; the site needs it in full. */const src = (media: Doc | null | undefined) => (media?.url ? new URL(media.url, cms).href : "");const pair = (images: Doc[] | null | undefined) => [src(images?.[0]), src(images?.[1])];const text = (value: string | null | undefined) => value ?? "";const optional = (value: string | null | undefined) => value || undefined;const highlighted = (items: Doc[] | null | undefined) => (items ?? []).map((item) => ({ highlight: optional(item.highlight), text: text(item.text) }));const lines = (items: Doc[] | null | undefined) => (items ?? []).map((item) => text(item.text));
function block(item: Doc): PostBlock { if (item.blockType === "heading") return { type: "heading", text: text(item.text), bold: optional(item.bold) }; if (item.blockType === "paragraph") return { type: "paragraph", highlight: optional(item.highlight), text: text(item.text) }; return { type: "list", ordered: item.ordered ?? false, items: (item.items ?? []).map((row: Doc) => (row.content ?? []).map(block)) };}
export const toService = (doc: Doc) => ({ slug: doc.slug, number: text(doc.number), title: doc.title, images: pair(doc.images), description: text(doc.description), tools: (doc.tools ?? []).map((tool: Doc) => ({ label: text(tool.label), href: text(tool.href) })), offer: highlighted(doc.offer), benefits: highlighted(doc.benefits), plans: (doc.plans ?? []).map((plan: Doc) => ({ id: plan.key, label: text(plan.label), price: text(plan.price), note: text(plan.note), included: lines(plan.included), })),});
export const toProject = (doc: Doc) => ({ slug: doc.slug, title: doc.title, year: text(doc.year), image: src(doc.image), services: (doc.services ?? []).map((service: Doc) => ({ label: service.title, slug: service.slug })), summary: text(doc.summary), industry: text(doc.industry), overview: { text: text(doc.overview?.text), image: src(doc.overview?.image) }, process: { text: text(doc.process?.text), images: pair(doc.process?.images) }, result: { items: highlighted(doc.result?.items), image: src(doc.result?.image) },});
export const toPost = (doc: Doc) => ({ slug: doc.slug, title: doc.title, excerpt: text(doc.excerpt), image: src(doc.image), category: text(doc.category), date: String(doc.date).slice(0, 10), body: (doc.body ?? []).map(block),});
export const toCareer = (doc: Doc) => ({ slug: doc.slug, title: doc.title, summary: text(doc.summary), responsibilities: lines(doc.responsibilities), requirements: lines(doc.requirements), benefits: lines(doc.benefits),});
export const toTeamMember = (doc: Doc) => ({ name: doc.name, role: text(doc.role), email: text(doc.email), photo: src(doc.photo) });
export const toTestimonial = (doc: Doc) => ({ quote: doc.quote, name: doc.name, company: text(doc.company), service: text(doc.service), photo: src(doc.photo), logo: src(doc.logo),});The collections. Replace nitip-astro/src/content.config.ts — the schemas stay, only the loaders change:
/* * Collections: loaded from Payload (src/lib/payload.ts) and validated with the schemas in src/content/schemas.ts. * Items are keyed by `slug` (team and testimonials by name); `position` keeps the order set in the admin, which * getCollection doesn't (src/lib/content.ts sorts by it). */import { defineCollection } from "astro:content";import { z } from "astro/zod";import { careerSchema, postSchema, projectSchema, serviceSchema, teamMemberSchema, testimonialSchema,} from "./content/schemas";import { payload, toCareer, toPost, toProject, toService, toTeamMember, toTestimonial } from "./lib/payload";
const position = { position: z.number().optional() };
export const collections = { projects: defineCollection({ loader: payload("projects", toProject), schema: projectSchema.extend(position) }), services: defineCollection({ loader: payload("services", toService), schema: serviceSchema.extend(position) }), posts: defineCollection({ loader: payload("posts", toPost), schema: postSchema.extend(position) }), careers: defineCollection({ loader: payload("careers", toCareer), schema: careerSchema.extend(position) }), team: defineCollection({ loader: payload("team", toTeamMember, "name"), schema: teamMemberSchema.extend(position) }), testimonials: defineCollection({ loader: payload("testimonials", toTestimonial, "name"), schema: testimonialSchema.extend(position), }),};Instant updates while you work. Create nitip-astro/src/integrations/cms-refresh.ts, which lets Payload tell the
dev server to reload the collections:
/* * Dev only: POST /_refresh reloads the collections from the CMS, so a save in Payload shows on the next page load * without restarting `npm run dev`. Payload calls it through PUBLISH_HOOK_URL. A build reads the CMS anyway, so * nothing is added there. */import type { AstroIntegration } from "astro";
export default function cmsRefresh(): AstroIntegration { return { name: "cms-refresh", hooks: { "astro:server:setup": ({ server, refreshContent, logger }) => { server.middlewares.use("/_refresh", async (req, res) => { if (req.method !== "POST" || !refreshContent) { res.statusCode = 405; res.end(); return; } try { await refreshContent({}); logger.info("Collections reloaded from the CMS."); res.end("Refreshed"); } catch (error) { /* Usually an entry the schemas reject; the message names it. */ logger.error(`Couldn't reload the collections: ${error instanceof Error ? error.message : error}`); res.statusCode = 500; res.end("Refresh failed"); } }); }, }, };}astro.config.mjs — add the integration, and allow images from the CMS so Astro can download and resize them:
// @ts-checkimport tailwindcss from "@tailwindcss/vite";import { defineConfig, fontProviders } from "astro/config";import { loadEnv } from "vite";import cmsRefresh from "./src/integrations/cms-refresh";import forms from "./src/integrations/forms";import siteContent from "./src/content/site.json" with { type: "json" };const { PUBLIC_FORM_MODE } = loadEnv(process.env.NODE_ENV ?? "production", process.cwd(), "");const { PUBLIC_FORM_MODE, CMS_URL = "http://localhost:3000" } = loadEnv(process.env.NODE_ENV ?? "production", process.cwd(), "");
export default defineConfig({ // … /* PUBLIC_FORM_MODE=server adds the form route; see src/lib/forms/README.md. */ integrations: [forms(PUBLIC_FORM_MODE)], integrations: [forms(PUBLIC_FORM_MODE), cmsRefresh()], /* Images come from the CMS: Astro downloads, resizes and converts them when it builds. */ image: { domains: [new URL(CMS_URL).hostname] },6. Publish on save
Section titled “6. Publish on save”Tell Payload which URL to call after a save. While you work, that’s the Astro dev server. Add to nitip-cms/.env:
# Called after every save: the Astro dev server locally, your host's deploy hook in production.PUBLISH_HOOK_URL=http://localhost:4321/_refreshRestart the CMS (Ctrl+C, then npm run dev) so it picks up the new variable.
7. Try it
Section titled “7. Try it”With the CMS running, open a second terminal and start the site in nitip-astro:
npm run dev- Open localhost:4321 — the site looks exactly as before, now built from Payload.
- In the admin, open Posts, pick one, change the title and click Save.
- Reload the post on the site (or the blog list, or the home page): the new title is there.
- Click Create New in Posts, fill in a post, save, and open
/blog/<its slug>.
Then build the static site from the CMS:
npm run buildnpm run previewdist/ now holds the whole site, with Payload’s content and images baked in — the same plain files as before.
What stays in the code
Section titled “What stays in the code”- Page copy — headings, intros, the About text, button labels — is in
src/content/pages/, and the site details (name, contact, social links) insrc/content/site.json. They change rarely, so they stay in the repository. Move them to Payload globals later if editors need them. src/content/collections/keeps the demo JSON for the seed. The site no longer reads it.- The style guide page (
/style-guide) shows the first project, service and post from Payload, so keep at least one of each published — or deletesrc/pages/style-guide.astroif you don’t need it. - The contact and newsletter forms work as before (Forms).
Going live
Section titled “Going live”There are two things to host now: the CMS and the site.
- The CMS is a Next.js app. On a server or a host with a lasting disk (a VPS, Railway, Render with a disk) it runs
as it is —
npm run build, thennpm start— with its.env, keepingnitip.dbandmedia/between deploys. On serverless hosts like Vercel, switch to Postgres (@payloadcms/db-postgres, with migrations) and keep images in cloud storage (storage adapters). - The site deploys as before (Vercel, Netlify, Cloudflare Pages), with one environment variable:
CMS_URL, the CMS’s public address. The build fetches the content and images from it. - Publishing: create a deploy hook for the site on its host (a URL that starts a build) and set it as
PUBLISH_HOOK_URLin the CMS’s environment. Every save then rebuilds the site, live a minute or two later. Several saves in a row start several builds; hosts queue them.
We tested every step above locally, including the static build from the CMS; the hosting steps follow Payload’s and
the hosts’ own guides, so check them for the details (Payload deployment).
Keep PAYLOAD_SECRET and database URLs in the hosts’ environment variables, never in Git.
Troubleshooting
Section titled “Troubleshooting”Couldn't load projects from Payload at http://localhost:3000. Is the CMS running? The site reads Payload while it
starts and builds. Start the CMS first (npm run dev in nitip-cms), or fix CMS_URL in nitip-astro/.env.
FailedToFetchRemoteImageDimensions while building. To read each image’s size, Astro fetches it from the CMS and
closes the connection after the first bytes, so a build opens hundreds of new connections; on a slow network one can
miss Node’s 10-second limit. We saw it with Sanity’s image CDN, and the fix from that guide works here too — in
astro.config.mjs, share a few connections with a longer limit (npm install -D undici first):
import { Agent, setGlobalDispatcher } from "undici";setGlobalDispatcher(new Agent({ connections: 8, connect: { timeout: 60_000 } }));Remote image … is not allowed by your image configuration. astro.config.mjs is missing the image line from step 5, or CMS_URL points to
another host than the images.
A save doesn’t show in development. Check PUBLISH_HOOK_URL in nitip-cms/.env (the port must be the one
npm run dev prints for the site) and restart the CMS after changing it. Restarting the site’s dev server also reloads
everything.
Another astro dev server is already running. Astro 7 runs one dev server per project: stop the old one with
npx astro dev stop, or replace it with npm run dev -- --force.
The build stops on an entry, or the dev server logs Couldn’t reload the collections. The schemas check every entry, and the message names the collection and the field — usually a required image or text left empty in the admin. Until it’s fixed, the dev server lists only the entries before it in that collection. Fill it in and save.
The seed can’t find the files. It reads ../nitip-astro: keep both folders side by side, or change site at the
top of src/seed.ts. To start the seed over, stop the CMS, delete nitip.db and the media folder, and run it again.
Still stuck? Contact support with the step and the error message.
Learn more
Section titled “Learn more”- Payload: collections and fields
- Payload: REST API — what
src/lib/payload.tsreads - Astro: content collections — loaders and schemas
- Use a headless CMS with an Astro template — how every template’s collections work, and the other CMS options