I spent a week on Inertia Rails and SSR setup. I wrote this so you don't have to

3 days ago 2

I’ve never been great at CSS or front-end styling, so I lean on frameworks to pick up the slack. Back in the day, Bootstrap plus a good theme was all I needed. Lately, though, the community has drifted toward Tailwind CSS, and high-quality Bootstrap themes have become harder to find.

While looking for a modern alternative, I stumbled onto shadcn UI paired with v0.dev—an amazingly productive combo for generating slick UIs. The catch? Their output is pure React and TypeScript, which doesn’t mesh with Rails’ Hotwire-first philosophy (HTML over the wire).

That realization pushed me down a different path: I spun up a FastAPI back end (great for AI-related libraries) and used Next.js plus v0.dev for the front end. Development speed was insane—easily 10× faster than hand-rolling UI. The honeymoon ended on the server side, though: FastAPI was missing a lot of the batteries-included conveniences I’d taken for granted in Rails. Tasks that used to take hours in Rails stretched into days.

So I weighed my options:

  1. Rails API + Next.js
  2. Next.js front end proxy with a Rails app for some urls (this “Flexile” repo)

Vercel’s unpredictable bills made me nervous about a pure Next.js deployment, and I normally host with Hetzner using Kamal. Something about the setup still felt off.

Friends suggested trying Inertia.js with Rails so I could reuse the shadcn UI components generated by v0.dev. My project needs server-side rendering (SSR) for marketing pages and rich client-side interactions inside the app itself. My first idea: use Rails + Hotwire for SSR pages, then switch to Inertia for the complex parts. Reality check:

  • How can I share UI CSS between two build pipelines. (Yes, you can build Hotwire with Vite and don’t use importmap)
  • v0.dev stopped generating static HTML—I’d be stuck copy-pasting and tweaking markup by hand.
  • Keeping two very different mental models (Hotwire and Inertia) alive at once felt exhausting.

The epiphany came when I realized Inertia now supports SSR. Goodbye, Hotwire split-brain; hello single-stack Rails + Inertia.

That’s when the real headaches began. Nearly every tutorial I found was three years old, the docs were confusing and incomplete, and most SSR examples were nothing more than abandoned placeholders. Add Kamal’s quirky deployment steps on top, and I spent an entire week digging through repos just to get things working.

To spare you that pain, I documented the whole process to setup the project here.

Hope it saves you a ton of time—happy hacking!

Note: I’m using on macOS. This post is for anyone setting up a project the same way I did.



Part 1 – Basic setup and getting the app running

Create a new project:

rails new inertia_rails -d postgresql --skip-javascript

Add inertia_rails:

bundle add inertia_rails

Install the front‑end stack:

bin/rails generate inertia:install \ --framework=react \ --typescript \ --vite \ --tailwind \ --no-interactive

That command generates the front‑end files inside app/frontend.

During setup you’ll hit a conflict on bin/dev. Choose Y so the installer overwrites the file.

Because we’re using PostgreSQL, create the databases and run the migrations:

bin/rails db:setup && bin/rails db:migrate

Start the dev servers:

bin/dev

You’ll notice Rails starts on port 3100 instead of the usual 3000. This oddity is caused by foreman (or overmind — I forget which). Fix it by tweaking Procfile.dev.

Current Procfile.dev:

vite: bin/vite dev web: bin/rails s

Move the web line above vite:

web: bin/rails s vite: bin/vite dev

Restart with bin/dev and Rails will listen on localhost:3000.



Part 2 – Small dev tweaks, ShadcnUI, and server‑side rendering (SSR)

[Tiny dev tweak]

Visit http://localhost:3000/inertia-example to see the result.

If you go to http://127.0.0.1:3000/inertia-example you’ll hit a failed to connect to Vite dev server error.

Fix it by editing config/vite.json and adding "host": "127.0.0.1" inside the development block:

{ "all": { "sourceCodeDir": "app/frontend", "watchAdditionalPaths": [] }, "development": { "autoBuild": true, "publicOutputDir": "vite-dev", "port": 3036, "host": "127.0.0.1" // <‑‑ added line }, "test": { "autoBuild": true, "publicOutputDir": "vite-test", "port": 3037 } }

[Install ShadcnUI]

(follow the cookbook https://inertia-rails.dev/cookbook/integrating-shadcn-ui)

I wasted a fair bit of time here because I read too quickly. You actually have to modify tsconfig.app.json and tsconfig.json.

tsconfig.app.json – add:

{ "compilerOptions": { // … "baseUrl": ".", "paths": { "@/*": ["./app/frontend/*"] } } // … }

tsconfig.json – add:

{ // … "compilerOptions": { /* Required for shadcn-ui/ui */ "baseUrl": "./app/frontend", "paths": { "@/*": ["./*"] } } }

Here is the full tsconfig.app.json after editing:

{ "compilerOptions": { "composite": true, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, /* ShadcnUI */ "baseUrl": ".", "paths": { "@/*": ["./app/frontend/*"] } }, "include": ["app/frontend"] }

Here is the full tsconfig.json after editing:

{ "files": [], "references": [ { "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" } ], /* Required for shadcn-ui/ui */ "compilerOptions": { "baseUrl": "./app/frontend", "paths": { "@/*": ["./*"] } } }

Initialize ShadcnUI:

npx shadcn@latest init

Pick a theme (I chose Neutral). If you hit conflicts, just use --force.

At the moment ShadcnUI + Tailwind4 has a few hiccups with React19. If you don’t want warnings, temporarily downgrade to React18.

Test by adding a component:

npx shadcn@latest add button

If you see output like this, you’re good:

✔ Checking registry. Installing dependencies. It looks like you are using React 19. Some packages may fail to install due to peer dependency issues in npm (see https://ui.shadcn.com/react-19). ✔ How would you like to proceed? › Use --force ✔ Installing dependencies. ✔ Created 1 file: - app/frontend/components/ui/button.tsx

Update app/frontend/pages/InertiaExample.tsx:

import { Head } from '@inertiajs/react' import { Button } from '@/components/ui/button' // added export default function InertiaExample({ name }: { name: string }) { return ( <> <Head title="Inertia + Vite Ruby + React Example" /> <div> <Button>Example Button</Button> </div> </> ) }

Restart the dev server and visit http://localhost:3000/inertia-example. If you see the button you’ve succeeded.


[Server‑Side Rendering (SSR)]

Follow the official guide with one small tweak.

Create app/frontend/ssr/ssr.tsx:

import { createInertiaApp } from '@inertiajs/react' import createServer from '@inertiajs/react/server' import ReactDOMServer from 'react-dom/server' createServer((page) => createInertiaApp({ page, render: ReactDOMServer.renderToString, resolve: (name) => { const pages = import.meta.glob('../pages/**/*.tsx', { eager: true }) return pages[`../pages/${name}.tsx`] }, setup: ({ App, props }) => <App {...props} />, }), )

Important: change .jsx to .tsx because this is a TypeScript project. I lost 2 days on that oversight.

Now tweak app/frontend/entrypoints/inertia.ts so it supports SSR in production. We add hydrateRoot for production renders.

import { createInertiaApp } from '@inertiajs/react' import { createElement, ReactNode } from 'react' import { createRoot, hydrateRoot } from 'react-dom/client' // Add hydrateRoot here // Temporary type definition, until @inertiajs/react provides one type ResolvedComponent = { default: ReactNode layout?: (page: ReactNode) => ReactNode } createInertiaApp({ // Set default page title // see https://inertia-rails.dev/guide/title-and-meta // // title: title => title ? `${title} - App` : 'App', // Disable progress bar // // see https://inertia-rails.dev/guide/progress-indicators // progress: false, resolve: (name) => { const pages = import.meta.glob<ResolvedComponent>('../pages/**/*.tsx', { eager: true, }) const page = pages[`../pages/${name}.tsx`] if (!page) { console.error(`Missing Inertia page component: '${name}.tsx'`) } // To use a default layout, import the Layout component // and use the following line. // see https://inertia-rails.dev/guide/pages#default-layouts // // page.default.layout ||= (page) => createElement(Layout, null, page) return page }, setup({ el, App, props }) { if (el) { if (import.meta.env.MODE === "production") { // Add hydrateRoot here hydrateRoot(el, createElement(App, props)) // Add hydrateRoot here } else { createRoot(el).render(createElement(App, props)) } } else { console.error( 'Missing root element.\n\n' + 'If you see this error, it probably means you load Inertia.js on non-Inertia pages.\n' + 'Consider moving <%= vite_typescript_tag "inertia" %> to the Inertia-specific layout instead.', ) } }, })

Enable SSR builds for production in config/vite.json:

{ "all": { "sourceCodeDir": "app/frontend", "watchAdditionalPaths": [] }, "production": { // Add production ssr build config here "ssrBuildEnabled": true }, "development": { "autoBuild": true, "publicOutputDir": "vite-dev", "port": 3036 }, "test": { "autoBuild": true, "publicOutputDir": "vite-test", "port": 3037 } }

That’s all the config changes.

How do you confirm SSR is working?

Two ways:

  1. Build locally in production mode.
  2. Deploy to a real server.

Method 1– Local production build

Switch to production mode:

export RAILS_ENV=production

Build assets (dummy key is fine locally):

SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile

You should see output something like:

Building with Vite ⚡️ vite v5.4.19 building for production… transforming… ✓ 634 modules transformed. vite v5.4.19 building SSR bundle for production… ../../public/vite-ssr/ssr.js 3.08 kB │ map: 4.65 kB ✓ built in 36ms

Note the vite-ssr/ssr.js bundle—that’s our server‑rendered build.

Open two terminal tabs:

Tab 1 (Rails, still in production mode):

export RAILS_ENV=production bin/rails s

Tab 2 (SSR server):

bin/vite ssr

Visit http://localhost:3000/inertia-example, inspect the response HTML in Network tab, and you should see something like:

<div><button>…</button></div>

If you shut down bin/vite ssr and refresh, the markup disappears and the console shows an error—proof that SSR is actually working.


Part 3 – Deploy with Kamal (SSR test method 2)

Overview

  • Adjust the Dockerfile to include NodeJS for the Vite build.
  • Update Kamal config files.

Dockerfile tweaks

Add NodeJS to the default generated Dockerfile of Rails 8, so the container can build JavaScript:

# Install JavaScript dependencies ARG NODE_VERSION=22.13.1 ENV PATH=/usr/local/node/bin:$PATH RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && /tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && rm -rf /tmp/node-build-master

Install node modules in the build stage:

# Install node modules COPY package.json package-lock.json ./ RUN npm ci && rm -rf ~/.npm

Below is the full Dockerfile:

# syntax=docker/dockerfile:1 # check=error=true # This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: # docker build -t inertia_rails . # docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name inertia_rails inertia_rails # For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html # Make sure RUBY_VERSION matches the Ruby version in .ruby-version ARG RUBY_VERSION=3.4.3 FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base # Rails app lives here WORKDIR /rails # Install base packages RUN apt-get update -qq && \ apt-get install --no-install-recommends -y curl libjemalloc2 libvips postgresql-client && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives # Install JavaScript dependencies ARG NODE_VERSION=22.13.1 ENV PATH=/usr/local/node/bin:$PATH RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \ /tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \ rm -rf /tmp/node-build-master # Set production environment ENV RAILS_ENV="production" \ BUNDLE_DEPLOYMENT="1" \ BUNDLE_PATH="/usr/local/bundle" \ BUNDLE_WITHOUT="development" # Throw-away build stage to reduce size of final image FROM base AS build # Install packages needed to build gems RUN apt-get update -qq && \ apt-get install --no-install-recommends -y build-essential git libpq-dev libyaml-dev pkg-config && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives # Install application gems COPY Gemfile Gemfile.lock ./ RUN bundle install && \ rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ bundle exec bootsnap precompile --gemfile # Install node modules COPY package.json package-lock.json ./ RUN npm ci && \ rm -rf ~/.npm # Copy application code COPY . . # Precompile bootsnap code for faster boot times RUN bundle exec bootsnap precompile app/ lib/ # Precompiling assets for production without requiring secret RAILS_MASTER_KEY RUN SECRET_KEY_BASE_DUMMY=1 bundle exec rails assets:precompile # Final stage for app image FROM base # Copy built artifacts: gems, application COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" COPY --from=build /rails /rails # Run and own only the runtime files as a non-root user for security RUN groupadd --system --gid 1000 rails && \ useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ chown -R rails:rails db log storage tmp USER 1000:1000 # Entrypoint prepares the database. ENTRYPOINT ["/rails/bin/docker-entrypoint"] # Start server via Thruster by default, this can be overwritten at runtime EXPOSE 80 CMD ["./bin/thrust", "./bin/rails", "server"]

I looked at several sample Dockerfiles for Inertia‑Rails, but many include unnecessary steps like bin/vite build --ssr.

This command rails assets:precompile already handles SSR builds.

Update config/deploy.yml

(Replace values with your own.)

service: inertia_rails_example image: tuyenhx/inertia_rails_example servers: web: - 91.99.119.221 vite: hosts: - 91.99.119.221 cmd: bin/vite ssr options: network-alias: vite_ssr proxy: ssl: true host: inersample.ninzap.com registry: username: tuyenhx password: - KAMAL_REGISTRY_PASSWORD env: secret: - RAILS_MASTER_KEY - POSTGRES_PASSWORD clear: SOLID_QUEUE_IN_PUMA: true DB_HOST: inertia_rails_example-db INERTIA_SSR_URL: http://vite_ssr:13714 aliases: console: app exec --interactive --reuse "bin/rails console" shell: app exec --interactive --reuse "bash" logs: app logs -f dbc: app exec --interactive --reuse "bin/rails dbconsole" volumes: - inertia_rails_storage:/rails/storage asset_path: /rails/public/assets builder: arch: - amd64 - arm64 accessories: db: image: postgres:16 host: 91.99.119.221 port: "127.0.0.1:5432:5432" # expose to localhost only env: clear: POSTGRES_USER: inertia_rails_example POSTGRES_DB: inertia_rails_example_production secret: - POSTGRES_PASSWORD files: - db/production.sql:/docker-entrypoint-initdb.d/setup.sql directories: - data:/var/lib/postgresql/data

Why those changes?

  • builder.arch – I build on an M3 Mac (arm64) and deploy to Hetzner (arm64), but I want to it to run on other architecture in the future, so I also add amd64.
  • accessories.db – because we use PostgreSQL we add a database accessory and a seed file. The port mapping 127.0.0.1:5432:5432 publishes the container port only to localhost (unlike just 5432, which would expose it publicly).

Create db/production.sql so the accessory can create extra databases used by Rails 8’s defaults for cache/queue/cable:

CREATE DATABASE inertia_rails_example_production_cache; CREATE DATABASE inertia_rails_example_production_queue; CREATE DATABASE inertia_rails_example_production_cable;

In config/database.yml tweak the production section:

production: primary: &primary_production <<: *default host: <%= ENV["DB_HOST"] %> # Update there database: inertia_rails_example_production username: inertia_rails_example password: <%= ENV["POSTGRES_PASSWORD"] %> # Update there

Now the INERTIA_SSR_URL and network‑alias bits:

vite: options: network-alias: vite_ssr env: ... clear: ... INERTIA_SSR_URL: http://vite_ssr:13714

We create a container network alias vite_ssr so the web container can reach the Vite SSR server at the fixed hostname. Inertia‑Rails automatically reads INERTIA_SSR_URL; it just isn’t in the official docs. I have to read the test code of inertia_rails to see this.

Because of this tight coupling you generally want one Vite SSR container per web container — fine for this project.

Secrets

Add .kamal/secrets so Kamal can pick up secrets from your shell variables:

# Grab the registry password from ENV KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD # Never commit config/master.key. Read it at build time. RAILS_MASTER_KEY=$(cat config/master.key) POSTGRES_PASSWORD=$POSTGRES_PASSWORD

Create an .env file (git‑ignored):

POSTGRES_PASSWORD=<your‑postgres‑password> KAMAL_REGISTRY_PASSWORD=<your‑docker‑hub‑password>

Load it:

export $(grep -v '^#' .env | xargs)

Deploy!

Kamal deploys via git, so commit first:

git add . git commit -m "initial commit"

Then:

kamal setup

Wait a few minutes, then:

kamal deploy

After a short while your app should be live at https://inersample.ninzap.com/inertia-example (replace with your domain).


That’s it — you now have an Inertia‑Rails + React + TypeScript + ShadcnUI app with SSR, running locally and deployed with Kamal.

Read Entire Article