Tom Ho avatar

Tom Ho

May 17, 2025

Inertial Rails project setup to use code generated from v0 (ShadcnUI, TailwindCSS4, React, TypeScript)

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:

  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.

About Tom Ho