Series: Payload CMS Deployment Guides | WAM DevTech
Difficulty: Beginner to Intermediate | Time: 30 minutes
Prerequisites: Node.js 20+, Git, a Railway account (free tier works)
Railway is the fastest path from zero to a running Payload CMS instance. If you need a proof of concept, a client demo, or a staging environment, this is how you get there in under an hour — without touching AWS, Docker registries, or load balancers.
This guide covers deploying Payload CMS 3.x with an Astro SSR frontend on Railway, including the two failure points that aren't documented anywhere: the importMap problem and the root layout trap. Both will silently break your deployment if you miss them.
For a production enterprise deployment on AWS with ECS Fargate, Aurora PostgreSQL, CloudFront, and WAF — that series is coming. This is the quickstart that gets you oriented first.
What We Are Building
- Payload CMS 3.x (Next.js 15 embedded) — your headless CMS and admin UI
- Astro SSR frontend — your public-facing website
- PostgreSQL — managed by Railway
- Both services deployed from a single GitHub monorepo
Step 1: Create the Project Structure
mkdir my-cms-project && cd my-cms-project
git init Create a monorepo with two services:
my-cms-project/
├── payload/ # Payload CMS + Next.js
├── frontend/ # Astro SSR
└── README.md Step 2: Scaffold Payload CMS
cd payload
npx create-payload-app@latest . --template blank When prompted, select PostgreSQL for the database and npm for the package manager.
Install the Lexical rich text editor:
npm install @payloadcms/richtext-lexical --legacy-peer-deps Step 3: Configure payload.config.ts
import { buildConfig } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { postgresAdapter } from '@payloadcms/db-postgres'
import path from 'path'
import { fileURLToPath } from 'url'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfig({
serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL || 'http://localhost:3000',
admin: { user: 'users' },
collections: [
{
slug: 'pages',
access: { read: () => true },
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'text', required: true },
{ name: 'content', type: 'richText', editor: lexicalEditor({}) },
{
name: 'status',
type: 'select',
defaultValue: 'draft',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
admin: { position: 'sidebar' },
},
],
},
],
db: postgresAdapter({
pool: { connectionString: process.env.DATABASE_URL },
}),
secret: process.env.PAYLOAD_SECRET || 'fallback-secret-change-this',
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
}) Step 4: The importMap — The Most Common Failure Point
This is the step that isn't documented clearly anywhere. Payload CMS 3.x uses React Server Components and needs to know at build time which client components to include in the bundle. Normally payload generate:importmap handles this automatically. In CI/CD environments — including Railway — that command cannot run because it requires a live database connection at build time.
Without a properly populated importMap, rich text fields silently disappear in the admin UI. No error. No warning. They just don't show up.
Create src/app/(payload)/importMap.js manually:
import { RichTextField as RichTextField_0 } from '@payloadcms/richtext-lexical/client'
import { RscEntryLexicalField as RscEntryLexicalField_1 } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalCell as RscEntryLexicalCell_2 } from '@payloadcms/richtext-lexical/rsc'
import { BoldFeatureClient as BoldFeatureClient_3 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_4 } from '@payloadcms/richtext-lexical/client'
import { UnderlineFeatureClient as UnderlineFeatureClient_5 } from '@payloadcms/richtext-lexical/client'
import { HeadingFeatureClient as HeadingFeatureClient_6 } from '@payloadcms/richtext-lexical/client'
import { ParagraphFeatureClient as ParagraphFeatureClient_7 } from '@payloadcms/richtext-lexical/client'
import { OrderedListFeatureClient as OrderedListFeatureClient_8 } from '@payloadcms/richtext-lexical/client'
import { UnorderedListFeatureClient as UnorderedListFeatureClient_9 } from '@payloadcms/richtext-lexical/client'
import { LinkFeatureClient as LinkFeatureClient_10 } from '@payloadcms/richtext-lexical/client'
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_11 } from '@payloadcms/richtext-lexical/client'
export const importMap = {
"@payloadcms/richtext-lexical/client#RichTextField": RichTextField_0,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_1,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_2,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_3,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_4,
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_5,
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_6,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_7,
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_8,
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_9,
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_10,
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_11,
} The critical entries are RscEntryLexicalField and RscEntryLexicalCell — they must come from the /rsc export path, not /client. This is where most tutorials go wrong.
Step 5: Configure the Layout Files
Create src/app/layout.tsx:
import React from 'react'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>
} <html><body> here. Payload's admin layout handles that. Adding it causes React hydration error #418, which is difficult to trace back to this cause.
Create src/app/(payload)/layout.tsx:
import React from 'react'
import { RootLayout } from '@payloadcms/next/layouts'
import { handleServerFunctions } from '@payloadcms/next/utilities'
import config from '@payload-config'
import '@payloadcms/next/css'
export default async function Layout({ children }: { children: React.ReactNode }) {
const serverFunction = await handleServerFunctions({
config: await config,
})
return (
<RootLayout config={await config} serverFunction={serverFunction}>
{children}
</RootLayout>
)
} Step 6: Scaffold the Astro Frontend
cd ../frontend
npm create astro@latest . -- --template minimal --install --no-git
npm install @astrojs/node Configure astro.config.mjs for SSR:
import { defineConfig } from 'astro/config'
import node from '@astrojs/node'
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
}) Create src/lib/payload.ts:
const PAYLOAD_URL = import.meta.env.PAYLOAD_URL || 'http://localhost:3000'
export async function getPages() {
const res = await fetch(
`${PAYLOAD_URL}/api/pages?where[status][equals]=published&limit=50`
)
if (!res.ok) return []
const data = await res.json()
return data.docs || []
}
export async function getPage(slug: string) {
const res = await fetch(
`${PAYLOAD_URL}/api/pages?where[slug][equals]=${encodeURIComponent(slug)}&where[status][equals]=published&limit=1`
)
if (!res.ok) return null
const data = await res.json()
return data.docs?.[0] || null
} where[status][equals]=published — not JSON encoding. The JSON form returns empty results silently.
Step 7: Deploy to Railway
7a. Push to GitHub
cd ..
git add .
git commit -m "Initial Payload CMS + Astro setup"
git remote add origin https://github.com/your-org/my-cms-project.git
git push -u origin main 7b. Create the Railway project
Go to railway.app, create a new project, click Add Service → GitHub Repo, and select your repo. Railway will detect the monorepo. Add two services: payload and frontend.
7c. Add PostgreSQL
Click Add Service → Database → PostgreSQL. Railway auto-generates DATABASE_URL.
7d. Set environment variables
Payload service:
DATABASE_URL=${{Postgres.DATABASE_URL}}
PAYLOAD_SECRET=your-random-secret-min-32-chars
PAYLOAD_PUBLIC_SERVER_URL=https://your-payload-service.railway.app
NODE_ENV=production Frontend service:
PAYLOAD_URL=https://your-payload-service.railway.app
NODE_ENV=production 7e. Set root directories
In each service's settings, set the Root Directory: /payload for the Payload service, /frontend for the frontend service. Railway builds and deploys both automatically on every push to main.
Step 8: Create Your First Admin User
Once deployed, visit https://your-payload-service.railway.app/admin. Payload will prompt you to create the first admin user on first load.
Troubleshooting
Rich text fields not appearing in admin
The importMap is incomplete. Verify RscEntryLexicalField and RscEntryLexicalCell are present and coming from the /rsc export path, not /client.
React hydration error #418
Your root layout.tsx is wrapping children in <html><body>. Remove it and return <>{children}</> only.
API returns empty results
Check your WHERE query format. Must use bracket notation: where[field][equals]=value.
generate:importmap fails in CI
This command requires a live database connection at build time. Use the manually populated importMap approach in Step 4.
What Comes Next
You now have a running headless CMS with a public-facing Astro frontend. Railway is the right choice for demos, staging environments, and early-stage projects. When you are ready to move to production-grade infrastructure — ECS Fargate, Aurora PostgreSQL, CloudFront, WAF, Secrets Manager — the AWS series covers every step.
The first post in that series covers the full architecture before a single AWS resource is created. Start there before touching the console.
This deployment pattern is what we used to build a live municipal CMS for Chippewa County, Michigan in 5 days — from an RFP document alone. Read the case study.