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:
- Rails API + Next.js
- 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.
- Sample code repo: https://github.com/darkamenosa/inertia_rails_ssr_example
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-javascriptAdd inertia_rails:
bundle add inertia_railsInstall the front‑end stack:
bin/rails generate inertia:install \ --framework=react \ --typescript \ --vite \ --tailwind \ --no-interactiveThat 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:migrateStart the dev servers:
bin/devYou’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 sMove the web line above vite:
web: bin/rails s vite: bin/vite devRestart 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 initPick 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 buttonIf 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.tsxUpdate 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:
- Build locally in production mode.
- Deploy to a real server.
Method 1– Local production build
Switch to production mode:
export RAILS_ENV=productionBuild assets (dummy key is fine locally):
SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompileYou 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 36msNote 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 sTab 2 (SSR server):
bin/vite ssrVisit 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-masterInstall node modules in the build stage:
# Install node modules COPY package.json package-lock.json ./ RUN npm ci && rm -rf ~/.npmBelow 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/dataWhy 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 thereNow the INERTIA_SSR_URL and network‑alias bits:
vite: … options: network-alias: vite_ssr env: ... clear: ... INERTIA_SSR_URL: http://vite_ssr:13714We 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_PASSWORDCreate 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 setupWait a few minutes, then:
kamal deployAfter 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.