Inertial Rails project setup to use code generated from v0 (ShadcnUI, TailwindCSS4, React, TypeScript)
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-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:
- Build locally in production mode.
- 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 just5432
, 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.