Loading...
Payload CMS Deployment Series: Part 0
April 6, 2026

Deploy Payload CMS 3.x with Astro SSR on Railway in 30 Minutes

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}</>
}
Critical: Do NOT wrap with <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
}
Important: Payload REST API WHERE queries use bracket notation: 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 ServiceGitHub Repo, and select your repo. Railway will detect the monorepo. Add two services: payload and frontend.

7c. Add PostgreSQL

Click Add ServiceDatabasePostgreSQL. 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.

Share Article
Building with Payload CMS?

We deploy headless CMS platforms at fixed price.

From proof of concept to production-grade AWS infrastructure. Fixed price confirmed within 48 hours.

Jae S. Jung has been building since 1997: infrastructure, SaaS platforms, legacy migrations, distributed teams across four continents. Not drawing diagrams and handing them off. Actually building. That's the philosophy behind WAM DevTech. AI doesn't replace nearly 30 years of that. It amplifies it.

Share Article