NestJS × React × Railway: Implementing a Blog UI and Deploying to Production

  • react
    react
  • nestjs
    nestjs
  • docker
    docker
  • prisma
    prisma
Published on 2024/10/25

Introduction

Up to this point in the series, we’ve combined NestJS and Prisma to design and prepare the API foundation.
We’ve completed data model design, relation definitions, migration management, and test environment setup, so the backend API is now at a practical level.

In this fifth part, we’ll finally move on to connecting the frontend (React).

The goal this time is not just to build an API in isolation, but to finish things so that you can actually manipulate data from the browser.

Overall architecture connecting frontend and backend

In this part, we’ll aim for the following overall architecture:

Image from Gyazo

  • Build the frontend UI with React and call the NestJS API
  • Configure NestJS to serve not only the API but also the static files built by React
  • Use PostgreSQL (Docker container) as the backend DB to handle data persistence and retrieval

In the end, we’ll use a Dockerfile and docker-compose to integrate everything into a single setup that runs smoothly across development, test, and production environments.

Recap of what’s already prepared

By the previous article (Part 4), we already had the following in place:

  • An article posting API (/posts endpoint) built with NestJS + Prisma is running
  • Connection to PostgreSQL (Docker) is configured
  • DB connections can be switched via environment variables (development and test)
  • The test environment can be reset
  • The integration design between PrismaClient and NestJS is established

In other words, the API side is already ready to be connected to the frontend. In this article, we’ll use this API from React to implement data fetching, display, and posting.


This article is intended for readers like:

  • You’ve built an API foundation with NestJS and Prisma and now want to hook up a frontend
  • You’re about to seriously start frontend development with React
  • You want to try full-stack development that also considers Docker and deployment architecture

We’ll again build up the application step by step, aiming for something you can actually develop and operate.

Overview of the series and where this article fits

  1. Getting started with web app development in NestJS — Project structure and configuration management while building a blog site
  2. Building a post API in NestJS — Basics of introducing Prisma and implementing CRUD
  3. Improving application reliability — Logging, error handling, and testing strategy
  4. Deep dive into Prisma and DB design — Models, relations, and operational design
  5. Building the UI with React and deploying to production — Docker and environment setup ← This article

https://shinagawa-web.com/en/blogs/nestjs-blog-series-setup-and-config

https://shinagawa-web.com/en/blogs/nestjs-blog-series-crud-and-prisma-intro

https://shinagawa-web.com/en/blogs/nestjs-blog-series-logging-error-testing

https://shinagawa-web.com/en/blogs/nestjs-blog-series-prisma-db-design

Goal for this article

This blog post documents the end-to-end flow of developing a blog app using NestJS + React + Prisma + PostgreSQL and deploying it to a production environment.

Concretely, we’ll aim for the following setup:

  • Develop frontend (React) and backend (NestJS) separately
  • Manage the DB schema with Prisma and connect to PostgreSQL
  • Use a modern frontend toolchain like Vite and Tailwind CSS
  • Use Docker for production build and integration
  • Use the cloud service “Railway” to publish the full-stack setup to production

Image from Gyazo

In the latter half of the article, we’ll also cover:

  • How to handle Vite environment variables and caveats (e.g., VITE_API_URL)
  • Prisma migrations and applying them to the production DB
  • Environment variable configuration and Docker build strategy on Railway
  • A setup where React build artifacts are served statically via NestJS

By following along, you’ll be able to “replay” the steps to build a “production-ready configuration” that can be used for personal projects, PoC work, or startup launches.

The code implemented in this article is stored in the following repository; feel free to refer to it alongside the article:

https://github.com/shinagawa-web/nestjs-blog

Creating the React app and basic structure

Here we’ll create a new React app and set up a structure that’s easy to develop in.

Creating the project with Vite (frontend directory)

There are several ways to bootstrap a React app, but here we’ll use Vite.
(You could also use Create React App, but Vite offers faster builds and a better development experience.)

First, in the root of the NestJS project (at the same level as /backend), create a frontend directory.

cd <project root>
npm create vite@latest frontend -- --template react-ts

What this command does:

  • Creates a new Vite app with React + TypeScript in a directory named frontend
  • --template react-ts enables TypeScript support from the start

Once creation is complete, install the dependencies:

cd frontend
npm install

This completes the initial setup of the React app.

/frontend directory structure

Right after setup, the frontend directory looks like this:

/frontend
  ├── public/
  ├── src/
      ├── App.tsx
      ├── main.tsx
  ├── index.html
  ├── package.json
  ├── tsconfig.json
  ├── vite.config.ts

You’ll build the React application screens by editing src/App.tsx.

Introducing Tailwind CSS

To quickly build a simple UI, we’ll introduce Tailwind CSS.

Install it with:

npm install tailwindcss @tailwindcss/vite

Next, configure vite.config.ts as follows:

vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

// https://vite.dev/config/
export default defineConfig({
  plugins: [react(), tailwindcss()],
})

Finally, create src/index.css and load Tailwind’s base styles:

@import "tailwindcss";

Verifying it works

Once setup is complete, start the development server and verify it works:

cd frontend
npm run dev

Open http://localhost:5173 in your browser; if you see the initial screen (Vite logo and “Vite + React”), you’re good.

To confirm Tailwind is working correctly, lightly edit App.tsx:

function App() {
  return (
    <div className="min-h-screen flex items-center justify-center bg-blue-100">
      <h1 className="text-4xl font-bold text-blue-800">
        Frontend is working!
      </h1>
    </div>
  );
}

export default App;

If, after reloading, the background changes to light blue (bg-blue-100) and the text to dark blue (text-blue-800), Tailwind is working properly.

Image from Gyazo

Integrating with NestJS and configuring API communication

Configuring the API endpoint

First, to send requests from the React app to the NestJS server, we’ll manage the API endpoint via environment variables. Create a frontend/.env file and add:

VITE_API_URL=http://localhost:3000

When referencing the API endpoint from React, use import.meta.env.VITE_API_URL.
(Due to Vite’s specification, environment variables must be prefixed with VITE_.)

Creating the post form (PostForm.tsx)

First, create a component for the post form.
Create frontend/src/components/PostForm.tsx and add:

frontend/src/components/PostForm.tsx
import { useState } from 'react';

type Props = {
  onPostCreated: () => void;
};

export function PostForm({ onPostCreated }: Props) {
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();

    await fetch(`${import.meta.env.VITE_API_URL}/posts`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title, content }),
    });

    setTitle('');
    setContent('');
    onPostCreated();
  }

  return (
    <form
      onSubmit={handleSubmit}
      className="bg-white p-8 rounded shadow-md space-y-4 w-full max-w-md"
    >
      <h1 className="text-2xl font-bold text-blue-700">New Post</h1>
      <div>
        <label className="block text-sm font-medium text-gray-700">Title</label>
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          className="mt-1 block w-full border-gray-300 rounded-md shadow-sm"
          required
        />
      </div>
      <div>
        <label className="block text-sm font-medium text-gray-700">Body</label>
        <textarea
          value={content}
          onChange={(e) => setContent(e.target.value)}
          className="mt-1 block w-full border-gray-300 rounded-md shadow-sm"
          rows={4}
        />
      </div>
      <button
        type="submit"
        className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 transition"
      >
        Post
      </button>
    </form>
  );
}
  • After posting, it calls the onPostCreated() callback so the parent component can refresh the list.

Creating the post list (PostList.tsx)

Next, create a component for the post list.
Create frontend/src/components/PostList.tsx and add:

frontend/src/components/PostList.tsx
import { useEffect, useState } from 'react';

type Post = {
  id: number;
  title: string;
  content?: string;
};

export function PostList() {
  const [posts, setPosts] = useState<Post[]>([]);

  async function fetchPosts() {
    const response = await fetch(`${import.meta.env.VITE_API_URL}/posts`);
    const data = await response.json();
    setPosts(data);
  }

  useEffect(() => {
    fetchPosts();
  }, []);

  return (
    <div className="mt-12 w-full max-w-2xl">
      <h2 className="text-xl font-bold mb-4 text-blue-700">Posts</h2>
      <ul className="space-y-4">
        {posts.map((post) => (
          <li key={post.id} className="p-4 bg-white rounded shadow">
            <h3 className="text-lg font-semibold">{post.title}</h3>
            {post.content && <p className="mt-2 text-gray-600">{post.content}</p>}
          </li>
        ))}
      </ul>
    </div>
  );
}
  • On initial render, it fetches and displays the list of posts.

App.tsx (parent component)

Finally, edit src/App.tsx as follows:

src/App.tsx
import { PostForm } from './components/PostForm';
import { PostList } from './components/PostList';
import { useState } from 'react';

function App() {
  const [refreshKey, setRefreshKey] = useState(0);

  function handlePostCreated() {
    setRefreshKey((prev) => prev + 1);
  }

  return (
    <div className="min-h-screen bg-gray-50 flex flex-col items-center p-8">
      <PostForm onPostCreated={handlePostCreated} />
      <PostList key={refreshKey} />
    </div>
  );
}

export default App;
  • After posting, it updates refreshKey to remount the PostList component and refresh the list.

Verifying it works

Once you’ve implemented everything up to this point, verify it works with the following steps:

  1. Start the React app with npm run dev
  2. Access http://localhost:5173
  3. Enter a title and body, then click the “Post” button
  4. If successful, the list will automatically refresh and the new post will appear

Image from Gyazo

Optionally, start npx prisma studio and confirm that the posted content is also stored in the DB

Image from Gyazo

If you get a CORS error

Due to browser behavior, when the React app (http://localhost:5173) sends a request to the NestJS server (http://localhost:3000),
a security check (preflight request) occurs.

To handle this on the NestJS side, add the following setting to src/main.ts:

src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { HttpExceptionFilter } from './common/filters/http/http.filter';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  app.useGlobalFilters(new HttpExceptionFilter());
+ app.enableCors();
  await app.listen(process.env.PORT ?? 3000);
}
bootstrap().catch((err) => {
  console.error('Application failed to start', err);
});

This will allow the browser’s requests properly, and posting will work smoothly.

Building the React app and serving it statically with NestJS

Now that the React app and NestJS API can communicate, we’ll set up NestJS to serve the built React app as static files.

This will let a single server handle both the API and the React UI from the browser, making the setup simple and easy to manage in both development and production.

Building the React app

First, build the React app in the frontend directory:

cd frontend
npm run build

If it finishes successfully, a frontend/dist directory will be generated.
This directory contains the complete set of files (HTML, CSS, JavaScript, etc.) that run in the browser.

Configuring NestJS to serve static files

To let NestJS serve React’s static files, add the following settings to backend/src/main.ts:

backend/src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { HttpExceptionFilter } from './common/filters/http/http.filter';
+ import { NestExpressApplication } from '@nestjs/platform-express';
+ import { join } from 'path';

async function bootstrap() {
+ const app = await NestFactory.create<NestExpressApplication>(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  app.useGlobalFilters(new HttpExceptionFilter());
  app.enableCors();
+ app.useStaticAssets(join(__dirname, '../../', 'frontend', 'dist'));
  await app.listen(process.env.PORT ?? 3000);
}
bootstrap().catch((err) => {
  console.error('Application failed to start', err);
});

Verifying it works

Start NestJS and access http://localhost:3000; you should see the screen created with React.

Once you can access it, also verify that you can create new posts.

Image from Gyazo

Organizing the path design for frontend and API

At this point, we can build the React app and serve it as static files from NestJS.
However, as it stands, there’s still a risk that the paths used by the React app and the NestJS API will conflict.

For example, when the browser accesses /posts,
NestJS might mistakenly treat it as a “static file,”
or API requests might accidentally be routed to React.

To clearly separate frontend navigation from backend API requests,
we’ll switch to a design where API requests use an /api/ prefix.

Why add an /api prefix?

  • Frontend routing (e.g., /posts, /about)
  • Backend API routing (e.g., /api/posts, /api/users)

By clearly separating these, we can:

  • Correctly return index.html on page reloads
  • Ensure API requests reliably reach NestJS

This leads to natural, trouble-free request handling.

Changes to make

NestJS

Add an /api prefix on the controller side.
For example, for the post API (PostsController), configure it like this:

backend/src/posts/posts.controller.ts
- @Controller('posts')
+ @Controller('api/posts')
export class PostsController {
  constructor(private readonly postsService: PostsService) {}

  @Post()
  create(@Body() createPostDto: CreatePostDto) {
    return this.postsService.create(createPostDto);
  }

React

Edit the frontend/.env file as follows:

VITE_API_URL=http://localhost:3000/api

Verifying it works

Restart NestJS and confirm that creating new posts and listing posts still works.

Building an integrated environment (frontend + backend + db) with Docker Compose

So far, we’ve gotten the frontend, backend, and database (PostgreSQL) each running individually. Next, we’ll build a development environment that can start all of them at once using Docker Compose.

Specifically, we’ll create:

  • docker-compose.dev.yml → Development environment (with hot reload)
  • docker-compose.prod.yml → Production environment (built and optimized)

so that we can choose the best startup method for each scenario.
This will make both development and deployment smoother and safer.

Creating docker-compose.dev.yml (for development)

During development, real-time reflection of source code changes is the top priority. To achieve this, we’ll use volume mounts to sync the host and containers, and run both NestJS and React with hot reload.

docker-compose.dev.yml

services:
  db:
    image: postgres:15
    container_name: nestjs-postgres-dev
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: nestjs_blog_dev
    volumes:
      - db-data-dev:/var/lib/postgresql/data

  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile.dev
    container_name: nestjs-backend-dev
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://postgres:postgres@db:5432/nestjs_blog_dev
    volumes:
      - ./backend:/app
    command: npm run start:dev
    depends_on:
      - db

  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile.dev
    container_name: react-frontend-dev
    ports:
      - "3001:3000"
    volumes:
      - ./frontend:/app
      - /app/node_modules
    command: npm run dev
    depends_on:
      - backend

volumes:
  db-data-dev:

Preparing Dockerfile.dev

Place a development-specific Dockerfile for each service.

backend/Dockerfile.dev
FROM node:20

WORKDIR /app

COPY package*.json ./
RUN npm install

CMD ["npm", "run", "start:dev"]
frontend/Dockerfile.dev
FROM node:20

WORKDIR /app

COPY package*.json ./
RUN npm install

CMD ["npm", "run", "dev"]

We’ll adjust the port numbers so they’re organized and don’t conflict when running under Vite:

frontend/vite.config.ts
export default defineConfig({
  plugins: [react(), tailwindcss()],
+ server: {
+   host: "0.0.0.0",
+   port: 3000,
+ },
})

Both backend and frontend now run on port 3000 inside their containers, and we map the frontend to port 3001 on the host to avoid conflicts.

Since we created a new DB, we’ll run the migrations:

docker-compose exec backend npx prisma migrate deploy

Verifying it works

Start everything with:

docker-compose -f docker-compose.dev.yml up --build

You don’t need to build every time.
You only need --build when you change dependencies or Dockerfile.dev; otherwise, you can just run:

docker-compose -f docker-compose.dev.yml up

If you can access http://localhost:3001/ and see the posting screen, everything started correctly.

To confirm hot reload works, change “New Post” to “Post”:

frontend/src/components/PostForm.tsx
  return (
    <form
      onSubmit={handleSubmit}
      className="bg-white p-8 rounded shadow-md space-y-4 w-full max-w-md"
    >
-     <h1 className="text-2xl font-bold text-blue-700">New Post</h1>
+     <h1 className="text-2xl font-bold text-blue-700">Post</h1>
      <div>

If the change is reflected in the browser, you’re all set.

Image from Gyazo

Building a production environment with Railway

What is Railway?

Railway is a developer-focused cloud platform that lets you deploy backends and databases “as if putting them on rails,” with minimal friction.

https://railway.com/

Notable features include:

  • Docker support: As long as you have a Dockerfile, you can deploy almost any application as-is
  • DB provisioning: Create and connect managed DBs like PostgreSQL and Redis with a single click
  • Easy environment variable management: Set environment variables via a GUI; they’re applied per deployment
  • Free tier: A free plan sufficient for light testing and personal projects
  • Git integration: Connect to GitHub and deploy automatically on each push
  • Want to quickly stand up “something that works” in production
  • Want to manage frontend + backend + DB together
  • Are building apps based on Docker
  • Are looking for a Heroku alternative
  • Want to quickly iterate PoCs for technical validation or personal projects

Railway turned out to be a surprisingly smooth cloud platform for deploying a stack that combines Docker apps, NestJS, Prisma, and PostgreSQL. Rather than wrestling with complex CI/CD and infrastructure, it’s a very reliable option for “getting something running in production now.”

Overall flow of building the app on Railway

In this project, we’ll deploy a blog app built with NestJS and React to a production environment using the cloud platform Railway.
With Railway, you can flexibly build environments even with a Docker-based setup and manage the backend, frontend, and database together.

Here’s a rough outline of the steps to bring up the production environment:

  1. Prepare the Dockerfile

    • Configure both backend (NestJS) and frontend (React) to be buildable with Docker
    • Prepare a production Dockerfile
    • After building the frontend, serve it as static files from NestJS
  2. Create a Railway project

    • Log in to Railway and create a new project
    • Start with the “Deploy from GitHub” template
  3. Add a PostgreSQL instance

    • Select “Deploy PostgreSQL” to add PostgreSQL
    • Later use the automatically generated DATABASE_PUBLIC_URL environment variable
  4. Run Prisma migrations for production

    • Apply migrations to the production DB based on prisma/migrations
  5. Configure environment variables (Variables)

    • Register values equivalent to .env.production in Railway’s GUI
    • Ensure these are referenced during frontend and backend builds
  6. Trigger a deployment on Railway

    • Start deployment via GitHub integration
    • Build → start container → publish app according to the Dockerfile
  7. Verify and adjust

    • The app will be published at a URL like https://example.up.railway.app
    • Check for mismatches in API endpoints and environment variables
    • Rebuild/redeploy as needed

Preparing the Dockerfile

In production, we build the source code and containerize only the optimized artifacts.
We don’t need hot reload; we just build and run.
You may not often run the production environment locally, but we’ll use this Dockerfile later when deploying to external services.

# Build stage
FROM node:20 AS builder

WORKDIR /app

# Copy both backend and frontend
COPY backend/package*.json backend/
COPY frontend/package*.json frontend/

# Build frontend
WORKDIR /app/frontend
COPY frontend/ .
RUN npm install

ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL

RUN npm run build

# Build backend
WORKDIR /app/backend
COPY backend/ .
RUN npm install
RUN npm run build

RUN npx prisma generate

# Run stage
FROM node:20-slim

WORKDIR /app

# Copy backend and frontend together
COPY --from=builder /app .

ENV NODE_ENV=production
CMD ["node", "backend/dist/main.js"]

Check that the Dockerfile doesn’t produce errors by building and running a container:

docker build -t nestjs-blog-app .
docker run --rm -p 3000:3000 nestjs-blog-app

When you start the container, you’ll see an error during NestJS startup when it tries to access the database.
It’s trying to access the production database, which we haven’t created yet, so this error is expected and not a problem at this stage.

Image from Gyazo

Creating a Railway project

Log in to Railway and create a new project

Register an account from the following URL:

https://railway.com/

After registering, choose the repository you created for this project under “Deploy from GitHub repo.”

Image from Gyazo

You can configure which repositories Railway can access on the following screen (you don’t need to expose all your repositories to Railway):

Image from Gyazo

Deployment will likely start automatically based on the Dockerfile at the project root, but it will probably fail for now. You can leave it as is.

Adding a PostgreSQL instance

Next, we’ll set up a database for the production environment. On the following screen, select “Deploy PostgreSQL”:

Image from Gyazo

In about a minute, setup will complete and the connection information for the database will be displayed. Make a note of DATABASE_PUBLIC_URL.

Image from Gyazo

Running Prisma migrations for production

From your local environment, run migrations against the PostgreSQL instance you just created:

DATABASE_URL="..." npx prisma migrate deploy
  • DATABASE_URL: The DATABASE_PUBLIC_URL issued when you added the PostgreSQL instance

Once the migration completes successfully, you’ll be able to inspect the DB from Railway as well.

Image from Gyazo

Configuring environment variables (Variables)

Go back to the project that will be deployed via Docker and generate a domain for external access using “Generate Domain.” (Railway will generate a domain in the form https://example.up.railway.app.)

Image from Gyazo

This value will be the final URL you access from the browser.

In Variables, set DATABASE_URL and VITE_API_URL:

Image from Gyazo

  • DATABASE_URL: The DATABASE_PUBLIC_URL issued when you added the PostgreSQL instance
  • VITE_API_URL: The URL issued by “Generate Domain” with /api appended, i.e., ${URL}/api

Triggering a deployment on Railway

Now that everything is configured, let’s deploy and verify.
If you used “Deploy from GitHub repo,” deployments will run automatically when you commit to the target branch, but you can also deploy manually with “Redeploy.”

Image from Gyazo

Verifying it works

Access the URL issued by “Generate Domain,” and you should see the blog app we built and be able to create posts.

Image from Gyazo

Conclusion

In this article, we walked through the entire flow of building a blog app with NestJS + React + Prisma, creating a production build with Docker, and deploying it to the Railway cloud platform.

Rather than just running it locally, we also covered:

  • Production builds and environment switching
  • Prisma migrations and DB operations
  • Unified delivery of frontend and backend
  • Container management and environment variable configuration on Railway

These are all “preparations for actual operation,” which can seem tedious at first glance.
However, by carefully setting them up early in development, you gain a significant advantage later in team development, scaling, and handling production incidents.

The setup introduced here is also a simple yet powerful base that you can immediately adapt for personal projects or startup launches.

Going forward, we’ll tackle further operations-focused improvements (e.g., building a staging environment, monitoring, authentication).
Let’s continue building more practical application architectures.

I hope this article helps you grow your development and operations skills, even just a little.

Xでシェア
Facebookでシェア
LinkedInでシェア

Questions about this article 📝

If you have any questions or feedback about the content, please feel free to contact us.
Go to inquiry form