ReactHosting.dev
← All guides

Deploy a React + Express app to Railway

By Andrius Vaitiekūnas

Deploying a React frontend is easy. Deploying React together with an Express backend — as one app, on one domain, without a CORS maze — is where most tutorials stop short. This guide covers the whole path: structuring the project, making Express serve the React build, the one change almost everyone forgets, and getting it running on Railway with a database and a custom domain. It also says plainly when Railway is the wrong choice.

Affiliate disclosure: Some links on this page are affiliate links to hosting providers we cover. If you sign up through them, I may earn a small commission at no extra cost to you. This never affects which hosts are covered or how they're ranked.

What you're actually deploying

A React + Express app has two halves with completely different runtime needs:

Frontend The React app — a static bundle of HTML, JS and CSS produced by npm run build. It needs to be served, not executed.
Backend The Express server — a Node process that must stay running, hold connections and answer API requests.

The approach in this guide is to run one service: Express serves the API and the built React files. One deploy, one domain, one bill, and no CORS configuration at all — because the frontend and API share an origin.

Splitting them across two services is a legitimate choice, but it buys you two deployments, a CORS policy to maintain and a second thing to pay for. Do it when the halves genuinely need to scale or ship separately, not by default.

Project structure

A monorepo with separate client and server folders keeps the two halves independent while staying a single repo and a single deploy:

myapp/
  client/            ← React + Vite frontend
    src/
    dist/            ← built output (after npm run build)
    package.json
    vite.config.js
  server/            ← Express backend
    index.js
    routes/
  package.json       ← root scripts that drive both

Root package.json scripts. The build and start pair is what the host will call:

{
  "scripts": {
    "build": "npm install --prefix client && npm run build --prefix client",
    "start": "node server/index.js",
    "dev": "concurrently \"npm run dev --prefix client\" \"nodemon server/index.js\""
  }
}

Using --prefix client rather than cd client && keeps the script working the same way locally and inside a build container.

Make Express serve the React build

In production Express serves the static bundle itself. Add this to server/index.js:

import express from 'express'
import path from 'path'
import { fileURLToPath } from 'url'

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

const app = express()

app.use(express.json())

// API routes first — they must win over the catch-all below
app.use('/api', yourApiRouter)

// Then the built React app
const clientDist = path.join(__dirname, '../client/dist')
app.use(express.static(clientDist))

// Anything that isn't an API route is handed to React Router
app.get('*', (req, res) => {
  res.sendFile(path.join(clientDist, 'index.html'))
})

Order matters. The app.get('*') catch-all is what makes a hard refresh on /dashboard work instead of 404 — but if you register it before your API routes it will swallow them too.

This is the same SPA-routing problem static hosts solve with a rewrite rule. If you want the background, we cover it host by host in fixing the React Router 404 on refresh.

Configure Vite so dev and production agree

In development the React dev server and Express run on different ports, so you proxy API calls to avoid CORS. In production they share an origin and no proxy is needed. Keep the proxy in client/vite.config.js:

export default {
  server: {
    proxy: {
      '/api': 'http://localhost:3001'
    }
  }
}

Then use relative URLs everywhere in your React code:

const response = await fetch('/api/users')

One line that works in both places: proxied to Express in development, served by Express in production. No environment-specific API base URL to manage, and no CORS policy to get wrong.

The one change almost everyone forgets

Every managed host — Railway, Render, App Platform, Fly, Heroku — assigns your app a port at runtime and passes it in as the PORT environment variable. An app hardcoded to 3001 binds to a port nothing is routed to, and you get a deploy that looks successful and serves nothing.

const PORT = process.env.PORT || 3001

app.listen(PORT, '0.0.0.0', () => {
  console.log(`Server listening on ${PORT}`)
})

If you remember one thing from this guide, make it this. "Works locally, dead in production" is this bug more often than it is anything else, and the logs rarely say so plainly.

Deploying to Railway

With the app itself ready, the deploy is mostly configuration. Railway builds from a connected Git repository, so after the first setup you deploy by pushing.

1. Create a project from your repo

Sign in, create a new project, and point it at the repository holding the app. Railway inspects the repo and infers how to build a Node project — you'll correct the details in the next step if it guesses differently than you want.

2. Set the root directory, if you use a monorepo

If the service you're deploying doesn't live at the repository root, set the service's root directory so the build runs from the right folder. For the layout above — root scripts driving both halves — leave it at the repository root.

3. Set the build and start commands

These are the two scripts defined earlier. The build compiles the React bundle; the start command runs Express, which then serves it.

build:  npm run build
start:  npm start

4. Add your environment variables

Set them on the service rather than committing a .env file. Don't set PORT yourself — the platform provides it. Do set NODE_ENV=production, plus whatever secrets your app reads.

5. Deploy, then watch the logs

The first deploy is the one that finds problems. Read the build log to confirm the React build ran and produced client/dist, then the deploy log to confirm Express started and logged the port it bound to.

6. Expose it and add your domain

Generate a public URL to test against, then add your own domain and create the DNS record it asks for. Certificates are issued for you once DNS resolves.

Railway's dashboard changes shape from time to time, so treat the labels above as intent rather than exact button names — the six things that need to happen are stable even when the UI moves.

Adding Postgres

Most React + Express apps want a database, and this is where a platform like this earns its keep: add a Postgres instance to the same project and it becomes reachable from your service through an injected connection-string variable — no networking to configure, no separate provider to bill you.

import pg from 'pg'

const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL,
})

Reference the injected variable rather than copying the credentials into your own settings — if the database is later rotated or moved, a copied string goes stale and a reference doesn't.

Run migrations as part of your start command, or as a one-off from your machine against the same connection string. Avoid running them in the build step: builds can be retried or run concurrently, and migrations should not be.

Deploying updates

After the first setup there's no deploy script to maintain:

git push origin main

The push triggers a build, and the new version replaces the old one once it starts successfully. A failed build leaves the running version alone — which is the main practical advantage over deploying to a server by hand, where a broken build is discovered by your users.

When Railway isn't the right answer

This setup is not Railway-specific — the app runs anywhere that can execute a Node process. Three cases where something else fits better:

You want a fixed monthly number

Railway bills for what your services consume, which is cheap for small or spiky apps and harder to forecast as you grow. Render charges a fixed price per instance instead, and has a genuine free tier to start on — services sleep when idle, which is fine for a side project and not for production. We earn nothing from Render; for a steady app on a budget it is often simply the better pick. The full head-to-head is in Railway vs Render.

You expect to take over more of the stack

If you can see yourself wanting your own VMs, private networking and managed databases under one roof, DigitalOcean's App Platform deploys this same app from Git at fixed per-instance pricing, and droplets are there when you outgrow it — without changing vendors.

You need full control of the server

Specific system packages, unusual networking, or strict data placement all point at running the app yourself behind Nginx on a VPS. That's more work to set up and more to maintain, and it's the right call less often than people expect — but when it's right, it's clearly right.

Troubleshooting

The deploy succeeds but the URL doesn't respond

Almost always the port. Confirm the app reads process.env.PORT and that you haven't set a PORT variable of your own that overrides the one the platform injects.

Blank page, no errors

Express is running but can't find client/dist. Check the build log actually ran the React build, and check the path in express.static() resolves relative to server/index.js, not to the repository root.

API routes return the React app instead of JSON

The catch-all is registered before your API routes, so it matches first. Move app.use('/api', …) above app.get('*').

Refreshing a route 404s

The catch-all is missing entirely, or it's registered after a static handler that already returned 404. It must be the last route.

The build can't find your dependencies

A monorepo needs its client dependencies installed too. That's what npm install --prefix client in the build script is for — a bare npm install at the root won't reach it unless you've set up workspaces.

Database connections fail after a while

Use a connection pool rather than a client per request, and make sure the pool is created once at module scope — not inside a route handler, where every request opens a new one until the database refuses more.

Frequently asked questions

Do I need to split the frontend and backend into two services?

Not for most apps. Letting Express serve the built React files means one service, one domain, one deploy, and no CORS configuration at all. Split them when the two parts genuinely need to scale or ship independently — not by default.

Why does my app work locally but not after deploying?

The usual cause is the port. Railway assigns a port at runtime and passes it in as the PORT environment variable; an app hardcoded to 3001 binds to the wrong one and never receives traffic. Read process.env.PORT and fall back to a local default.

Can I use a monorepo with separate client and server folders?

Yes — that layout works well here. If your server lives in a subfolder, set the service root directory so the build runs from the right place, and make sure the build step still produces the React bundle where Express expects it.

Is Railway the right choice for a React + Express app?

It's a good fit when you want the fastest path from repo to a running server with a database beside it, and when your traffic is small or uneven — usage-based billing is cheap during quiet hours. If you want a fixed monthly figure instead, or a permanent free tier, Render and DigitalOcean's App Platform are the honest alternatives, and this guide covers when each is the better pick.

Railway deploys this setup from a Git repo and can put Postgres next to it in the same project. If you'd rather have a fixed monthly bill or a free tier to start on, Render runs the same app just as happily.

AV

Written by Andrius Vaitiekūnas

Developer who deploys and hosts React apps. I write the deployment guides I wish existed — tested on real projects, hard parts included.

About this site Contact

Related guides