Deploying a Strapi CMS on Railway and a Next.js Frontend on Vercel
Setting up a headless CMS stack with a free tier is surprisingly doable in 2026. This post walks through deploying Strapi v5 on Railway (with a managed PostgreSQL database) and connecting it to a Next.js frontend on Vercel — the exact setup powering this portfolio.
What we're building
- Strapi v5 — headless CMS running on Railway, backed by Railway-managed PostgreSQL
- Next.js — frontend on Vercel, fetching content from Strapi via the REST API
- pnpm monorepo — both apps live in the same repo under
apps/cms/andapps/web/
Prerequisites
- A Railway account
- A Vercel account
- Node.js 20+ and pnpm installed locally
- A GitHub repo with your Strapi + Next.js monorepo
Part 1: Deploying Strapi on Railway
1. Create a new Railway project
Go to railway.app, create a new project, and add two services:
- PostgreSQL — click "Add a service" → "Database" → "PostgreSQL"
- Your Strapi app — click "Add a service" → "GitHub Repo" → select your repo
For the Strapi service, set the Root Directory to apps/cms/ (or wherever your Strapi app lives in the monorepo).
2. Fix pnpm detection in a monorepo
This is the main gotcha. Railway uses Railpack as its build system, which detects the package manager by looking for a lockfile in the service root directory. If your pnpm-lock.yaml only exists at the monorepo root but your Railway root is set to apps/cms/, Railpack won't find it and will fall back to npm — causing the build to fail with pnpm: not found.
The fix: generate a standalone pnpm-lock.yaml inside apps/cms/ and add a packageManager field to its package.json.
# In apps/cms/
pnpm install --ignore-workspace
Then add to apps/cms/package.json:
{
"packageManager": "pnpm@10.24.0"
}
Commit both files. Railpack will now detect pnpm correctly.
3. Configure environment variables
In the Railway dashboard, set the following environment variables on your Strapi service:
HOST=0.0.0.0
NODE_ENV=production
URL=https://your-strapi-domain.com
DATABASE_CLIENT=postgres
DATABASE_URL=${{Postgres.DATABASE_URL}}
DATABASE_SSL=false
DATABASE_SCHEMA=public
APP_KEYS=<generate with: openssl rand -base64 32,openssl rand -base64 32>
API_TOKEN_SALT=<openssl rand -base64 32>
ADMIN_JWT_SECRET=<openssl rand -base64 32>
TRANSFER_TOKEN_SALT=<openssl rand -base64 32>
ENCRYPTION_KEY=<openssl rand -base64 32>
JWT_SECRET=<openssl rand -base64 32>
${{Postgres.DATABASE_URL}} is Railway's variable reference syntax — it automatically injects the PostgreSQL connection string from your database service.
4. Fix the admin panel URL
Without the URL config, Strapi builds its admin panel links using the raw host header, which can result in broken redirects (e.g. the admin panel pointing to http://0.0.0.0:PORT/your-domain/admin).
In config/server.ts, make sure url is set:
export default ({ env }) => ({
host: env("HOST", "0.0.0.0"),
port: env.int("PORT", 1337),
url: env("URL", ""),
app: {
keys: env.array("APP_KEYS"),
},
});
5. Set build and start commands
In Railway service settings:
- Build Command:
pnpm run build - Start Command:
pnpm run start
6. Add a custom domain (optional)
In Railway → your service → Settings → Domains, add your custom domain. Set up the DNS CNAME record as shown. Railway provisions a TLS certificate automatically.
7. Verify the deployment
Once deployed, visit https://your-strapi-domain.com/admin. You should see the Strapi admin login screen. Create your admin account and you're in.
Part 2: Deploying Next.js on Vercel
1. Create a Strapi API token
Before deploying the frontend, create a read-only API token in Strapi so your Next.js app can fetch content.
In the Strapi admin panel: Settings → API Tokens → Create new API Token
- Name: Vercel Web (or anything descriptive)
- Token type: Read-only
- Token duration: Unlimited
Copy the token — you won't see it again.
2. Make API permissions public (optional)
If you'd rather not use a token for public content, go to Settings → Users & Permissions → Roles → Public and enable find and findOne for each content type you want to expose. This is simpler but less secure.
3. Configure next.config.ts for image hosting
Strapi serves uploaded images from its own domain. Tell Next.js to allow images from your Strapi host:
// next.config.ts
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "your-strapi-domain.com",
pathname: "/uploads/**",
},
],
},
};
4. Add vercel.json for monorepo support
If your Next.js app is inside a monorepo (e.g. apps/web/), create a vercel.json at the repo root:
{
"framework": "nextjs",
"buildCommand": "pnpm --filter=web build",
"installCommand": "pnpm install",
"outputDirectory": "apps/web/.next"
}
This tells Vercel to:
- Run
pnpm installat the repo root (which resolves all workspace packages) - Run the
buildscript only for thewebworkspace - Pick up the output from
apps/web/.next
5. Import the project on Vercel
- Go to vercel.com/new
- Import your GitHub repo
- Vercel will auto-detect the
vercel.jsonand configure the build settings - Add the following environment variables before the first deploy:
| Variable | Value |
|---|---|
NEXT_PUBLIC_STRAPI_URL | https://your-strapi-domain.com |
STRAPI_API_TOKEN | The token you created in step 1 |
NEXT_PUBLIC_SITE_URL | https://your-vercel-domain.com |
- Click Deploy
6. Verify the deployment
Once deployed, your Next.js app should be live and fetching content from Strapi. Any content you publish in the Strapi admin will appear on the site within 60 seconds (the default ISR revalidation interval).
Connecting the two: CORS
If you're making client-side requests from your Next.js app to Strapi (e.g. for a comment form), you'll need to allow your Vercel domain in Strapi's CORS config.
In config/middlewares.ts:
export default [
"strapi::errors",
{
name: "strapi::security",
config: {
contentSecurityPolicy: {
useDefaults: true,
directives: {
"connect-src": ["'self'", "https:"],
},
},
},
},
{
name: "strapi::cors",
config: {
origin: ["https://your-vercel-domain.com"],
},
},
// ... rest of defaults
];
Summary
| Strapi CMS | Next.js Frontend | |
|---|---|---|
| Host | Railway | Vercel |
| Database | Railway PostgreSQL | — |
| Build detection | Railpack (needs lockfile in service root) | Vercel (via vercel.json) |
| Key env vars | DATABASE_URL, URL, APP_KEYS, secrets | NEXT_PUBLIC_STRAPI_URL, STRAPI_API_TOKEN |
| Custom domain | Railway → Settings → Domains | Vercel → Settings → Domains |
The biggest non-obvious issue is the Railpack lockfile detection in a monorepo — once you know about it, the fix is straightforward. Everything else is standard configuration.