Inertial Rails project setup to use code generated from v0 (ShadcnUI, TailwindCSS4, React, TypeScript) and deploy with Kamal
(Updated at: 2025/10/23)
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 runtime (prebuilt Node per-arch)
ARG NODE_VERSION=25.0.0
ARG TARGETARCH
ENV PATH=/usr/local/node/bin:$PATH
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y xz-utils && \
case "${TARGETARCH}" in \
amd64) NODEARCH=x64 ;; \
arm64) NODEARCH=arm64 ;; \
*) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
esac && \
mkdir -p /usr/local/node && \
curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODEARCH}.tar.xz" | \
tar -xJ -C /usr/local/node --strip-components=1 && \
/usr/local/node/bin/node -v && \
/usr/local/node/bin/npm -v && \
apt-get purge -y --auto-remove xz-utils && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
Why it is too complicated, I just want to ensure we install correct version for our CPU architecture, ensure maximum compatibility.
If you want to can add this to improve build speed. Ensure bundler version as same as your bundler version. (You can check your version by running this command bundler --version)
# Ensure the Bundler version matches Gemfile.lock to avoid per-build upgrades.
RUN gem install bundler -v 2.7.2 -N
No need to install node modules and related stuffs since bundle exec rails assets:precompile handle all.
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 runtime (prebuilt Node per-arch)
ARG NODE_VERSION=25.0.0
ARG TARGETARCH
ENV PATH=/usr/local/node/bin:$PATH
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y xz-utils && \
case "${TARGETARCH}" in \
amd64) NODEARCH=x64 ;; \
arm64) NODEARCH=arm64 ;; \
*) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
esac && \
mkdir -p /usr/local/node && \
curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODEARCH}.tar.xz" | \
tar -xJ -C /usr/local/node --strip-components=1 && \
/usr/local/node/bin/node -v && \
/usr/local/node/bin/npm -v && \
apt-get purge -y --auto-remove xz-utils && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Ensure the Bundler version matches Gemfile.lock to avoid per-build upgrades.
RUN gem install bundler -v 2.7.2 -N
# 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
# 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:
init: true # Add this option to speed up stop time after every deploy, currently it is 30s, b/c our vite command not handle SIGTERM correctly, this is optional you can remove if you want.
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:5432publishes 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:
init: true # Add this option to speed up stop speed after every deploy, , this is optional you can remove if you want.
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.