banner

If you just want to use Vite as a replacement for the Rails included Webpacker (as it was up to Rails 6), you can simply use Vite Ruby.

The goal of this article is different: to have 2 separate environments (Rails API and React frontend) using Vite, but with some degree of integration:

  • you can use Puma in production to serve the React App (i.e., one container only)
  • you can use node to automate any other task in the Rails app
  • start both servers with one single command

Requirements

I’m assuming you already have:

If you don’t have them, take a look in my previous post.

Rails Setup

We’ll call our app blogger, so create the Rails API with:

rails new blogger --api
cd blogger
rails g scaffold Post title:string content:text
rails db:migrate

Database Seed

Create one single post, appending to db/seeds.rb:

Post.create!(title: "First post", content: "This is the first post!")

Run rails db:seed.

CORS

As we’ll be running two different servers at different ports, CORS will block frontend requests to the API. Let’s enable it.

Open Gemfile and uncomment (or add) the following line:

gem "rack-cors"

Get it installed, so we can configure it:

bundle install

Allow the frontend to query the API editing config/initializers/cors.rb and appending the following configuration:

# TODO: Add production URLs to the list
# Use '*' if you want expose the API to the world
accept = [
  'http://127.0.0.1:5173', # Dev Frontend
  'http://localhost:5173', # Dev Frontend
  'http://127.0.0.1:5174', # Dev Frontend
  'http://localhost:5174', # Dev Frontend
]

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins accept
    resource '*',
      headers: :any,
      methods: %i[get post put delete options patch],
      expose: %w[Authorization]
  end
end

Foreman Backend Config

Later we’ll use Foreman to start all servers for us. Following its documentation recommends to not include it in the Gemfile, just install it with:

gem install foreman

Create the configuration for it, adding a file named Procfile to the project root folder with this content:

rails: bundle exec rails s -p 3000

Run bundle install.

Backend is configured. Time to install and configure the frontend.

Bonus: all above steps in a single take:

rails new blogger --api
cd blogger
rails g scaffold Post title:string content:text
rails db:migrate
echo 'Post.create!(title: "First post", content: "This is the first post!")' >> db/seeds.rb
rails db:seed
bundle add rack-cors
bundle install
printf '\n' >> config/initializers/cors.rb
wget https://gist.githubusercontent.com/raelgc/799cadce1303bd604a38bc3808d272b4/raw/1b401713a728178ed4ab86e061bbe1b0cc4d5033/cors.rb -O ->> config/initializers/cors.rb
gem install foreman
echo 'rails: bundle exec rails s -p 3000' >> Procfile

Vite Setup

npm create vite@latest . -- --template react-ts

If this is the first Vite app you’re creating, this prompt will appear (answer y):

Need to install the following packages:
  create-vite@5.1.0
Ok to proceed? (y)

After it, Vite will ask you what to do about the existing files in the project folder (answer to “Ignore files and continue”):

? Current directory is not empty. Please choose how to proceed: › - Use arrow-keys. Return to submit.
    Remove existing files and continue
    Cancel operation
❯   Ignore files and continue

Install all configured packages:

npm install

Moving frontend content

Create the folder for the React app and move Vite related files:

mkdir react
mv src react/
mv public react/
mv index.html react/
mv vite.config.ts react/
mkdir public && touch public/.keep

Update tsconfig.node.json with updated file placement and replace the line "include": ["vite.config.ts"] with:

"include": ["react/vite.config.ts", "react/src/**/*.d.ts"]

Update package.json scripts to point to react folder, replacing the scripts section with the following:

  "scripts": {
    "dev": "vite react",
    "build": "tsc && vite build react && touch public/.keep",
    "lint": "eslint react --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview react"
  },

Update react/vite.config.ts to build using the react/src folder and publish to the rails public folder (which will be always clean, aside the .keep file), adding the following inside defineConfig function:

  // Paste these lines inside defineConfig
  build: { 
    emptyOutDir: true,
    outDir: '../public' 
  },

Update tsconfig.json with the updated file placement and replace the line "include": ["src"], with:

  "include": ["react/src"],

Foreman Frontend Config

As it’s already installed, let’s just include the dev script into Foreman (i.e., append to Procfile):

web: npm run dev

Frontend configuration is ready. Now let’s do some requests to the backend.

Requests to the API

Install axios to easily make a request to the API:

npm install --save axios

Edit react/src/main.tsx and configure axios default base URL, placing the following content above ReactDOM.createRoot line:

// ... existing imports above
import axios from 'axios';

axios.defaults.baseURL = import.meta.env.VITE_APP_API_URL || 'http://127.0.0.1:3000';

If you’ll serve the API in a different URL, set an Env var for VITE_APP_API_URL. See how Vite handles Env variables.

Let’s update our React app to list all posts. Replace react/src/App.tsx entire content with:

import { useState, useEffect } from 'react'
import axios from 'axios'
import './App.css'

type Post = {
  id: number,
  title: string,
  content: string,
}

function App() {
  const [posts, setPosts] = useState<Post[]>([])

  useEffect(() => {
    axios.get('/posts').then(response => setPosts(response.data)).catch(console.error)
  }, [])

  return (
    posts.length === 0 
      ? <p>No posts</p>
      : (
        <>
          <h1>Posts</h1>
          {posts.map(post => (
            <>
              <h2>{post.title}</h2>
              <p>{post.content}</p>
            </>
          ))}
        </>
      )
  )
}

export default App

Serving static pages with Rails Server (Puma)

In a production environment, usually the frontend will be served by nginx. But it’s possible to have Rails serving both.

Add the rails-static-router gem to the Gemfile:

# Serve static index as catch-all route
gem "rails-static-router"

Install it:

bundle install

Add to config/routes.rb:

  if ENV["RAILS_SERVE_STATIC_FILES"].present?
    get "*path", to: static("index.html")
    post "*path", to: static("index.html")
  end

Scripts

  • Start backend: rails s
  • Start frontend: npm run dev
  • Start both (as foreman is installed): foreman start
  • Build for production: npm run build

If you want to test using rails server as production web server (usually it’d be nginx) in order to test:

  • First run the “Build for production” script
  • Then run: RAILS_SERVE_STATIC_FILES=1 RAILS_ENV=production rails s