Generate production-ready Rails applications with Inertia.js, React, TypeScript, Server-Side Rendering (SSR), and ShadcnUI components, configured for deployment with Kamal...
Generate complete, production-ready Rails applications with Inertia.js for SPA-like user experience, React + TypeScript frontend, Server-Side Rendering for SEO, ShadcnUI component library, and Kamal deployment configuration. The skill handles the entire setup process including Dockerfile configuration for SSR, database accessories, and deployment configuration.
Use this skill when:
Ask the user for project configuration:
Execute the appropriate Rails new command based on database choice:
PostgreSQL:
rails new PROJECT_NAME -d postgresql --skip-javascript
cd PROJECT_NAME
MySQL:
rails new PROJECT_NAME -d mysql --skip-javascript
cd PROJECT_NAME
SQLite:
# Default with Solid stack
rails new PROJECT_NAME --skip-javascript
cd PROJECT_NAME
# Or without Solid stack (if user requests)
rails new PROJECT_NAME --skip-javascript --skip-solid
cd PROJECT_NAME
Run these commands in sequence:
# Add Inertia Rails gem (current Inertia 3-compatible Rails adapter)
bundle add inertia_rails
# Install frontend stack (non-interactive)
bin/rails generate inertia:install \
--framework=react \
--typescript \
--vite \
--tailwind \
--no-interactive
When prompted about bin/dev conflict: Choose Y to overwrite.
After installation:
# Setup databases
bin/rails db:setup
bin/rails db:migrate
Target the current plugin-based stack:
inertia_rails 3.19+@inertiajs/react 3.x@inertiajs/vite 3.xThe generator should install @inertiajs/react, but always verify and install manually if missing:
@inertiajs/react in dependencies@inertiajs/vite in devDependencies (install with npm add -D @inertiajs/vite if the generator didn't add it)Also verify package.json builds both bundles:
{
"scripts": {
"dev": "vite",
"build": "vite build && vite build --ssr"
}
}
Rails 8 includes RuboCop Rails Omakase by default. Create or update .rubocop.yml:
# Omakase Ruby styling for Rails
inherit_gem: { rubocop-rails-omakase: rubocop.yml }
# Overwrite or add rules to create your own house style
#
# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
# Layout/SpaceInsideArrayLiteralBrackets:
# Enabled: false
# Restore strict 2-space indentation enforcement
Layout/IndentationConsistency:
Enabled: true
Layout/IndentationWidth:
Enabled: true
Width: 2
Why this matters:
Apply two critical fixes for development:
Fix 1: Procfile.dev (ensure Rails runs on port 3000)
Read Procfile.dev and swap the order:
web: bin/rails s
vite: bin/vite dev
Fix 2: config/vite.json (enable 127.0.0.1 access)
Add "host": "127.0.0.1" to development section:
{
"development": {
"autoBuild": true,
"publicOutputDir": "vite-dev",
"port": 3036,
"host": "127.0.0.1"
}
}
Configure TypeScript for ShadcnUI (CRITICAL: update BOTH files):
tsconfig.app.json - Add to compilerOptions:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./app/frontend/*"]
}
}
}
tsconfig.json - Add to root:
{
"compilerOptions": {
"baseUrl": "./app/frontend",
"paths": {
"@/*": ["./*"]
}
}
}
Initialize ShadcnUI (non-interactive):
npx shadcn@latest init --defaults --yes
npx shadcn@latest add button --yes --overwrite
Set up code linting and formatting for the TypeScript/React frontend. This config follows Vite's official react-ts template patterns and uses flat config (mandatory since ESLint v10).
Install dev dependencies:
npm add -D @eslint/js eslint@9 eslint-config-prettier eslint-plugin-import-x eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-react-refresh globals typescript-eslint@8 prettier
Note: Pin eslint@9 and typescript-eslint@8 — ESLint v10 just released but eslint-plugin-import-x doesn't yet support it.
Key design decisions:
eslint-config-prettier (disables conflicting rules) instead of eslint-plugin-prettier (runs Prettier as ESLint rule) — Prettier runs separately via npm run formateslint-plugin-react-refresh — essential for Vite HMR, catches components that break fast refresheslint-plugin-react-hooks v7 — includes React Compiler lint ruleseslint-plugin-import-x — modern fork of eslint-plugin-import (16 deps vs 117, supports exports field)globals — proper browser globals definitionCreate eslint.config.mjs:
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import importX from "eslint-plugin-import-x";
import prettierConfig from "eslint-config-prettier";
export default tseslint.config(
// Global ignores
{
ignores: [
"node_modules/**",
"public/**",
"vendor/**",
"tmp/**",
"log/**",
"storage/**",
"*.d.ts",
],
},
// Base JS + TypeScript recommended rules
js.configs.recommended,
...tseslint.configs.recommended,
// React JSX rules (flat config presets)
{
files: ["**/*.{jsx,tsx}"],
...react.configs.flat.recommended,
...react.configs.flat["jsx-runtime"],
settings: { react: { version: "detect" } },
rules: {
...react.configs.flat.recommended.rules,
...react.configs.flat["jsx-runtime"].rules,
"react/prop-types": "off",
"react/no-unescaped-entities": "off",
"react/no-unknown-property": ["error", { ignore: ["head-key"] }],
},
},
// React Hooks (official React team plugin, includes React Compiler rules)
{
files: ["**/*.{jsx,tsx}"],
...reactHooks.configs.flat.recommended,
},
// React Refresh (essential for Vite HMR)
{
files: ["**/*.{jsx,tsx}"],
...reactRefresh.configs.vite,
rules: {
...reactRefresh.configs.vite.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
// Import ordering and validation
{
files: ["**/*.{js,jsx,ts,tsx}"],
plugins: { "import-x": importX },
rules: {
"import-x/order": [
"error",
{
groups: ["builtin", "external", "internal", "parent", "sibling", "index"],
"newlines-between": "always",
alphabetize: { order: "asc", caseInsensitive: true },
},
],
"import-x/no-duplicates": "error",
},
},
// TypeScript-specific rules
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
globals: globals.browser,
},
rules: {
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports", fixStyle: "inline-type-imports" }],
},
},
// Disable ESLint rules that conflict with Prettier (Prettier runs separately)
prettierConfig,
// Console warnings
{
files: ["**/*.{js,jsx,ts,tsx}"],
rules: { "no-console": ["warn", { allow: ["warn", "error"] }] },
},
);
Add scripts to package.json:
{
"scripts": {
"check": "tsc -p tsconfig.app.json && tsc -p tsconfig.node.json",
"lint": "eslint app/frontend",
"lint:fix": "eslint app/frontend --fix",
"format": "prettier --write app/frontend",
"format:check": "prettier --check app/frontend"
}
}
Create a docs/ directory and write the coding style guides, plus a project-level CLAUDE.md that references them. These are read from the skill's asset files.
Create CLAUDE.md:
Copy from assets/CLAUDE.md — Project-level instructions for Claude Code including core rules, development workflow (backend/frontend conventions), strong parameters (Rails 8+), Inertia props convention (snake_case → camelCase), Rails patterns (REST-only controllers, rich domain models), project structure, commands, and architecture overview. The user should customize the "Product Context" section after setup.
Create docs/tailwind.md:
Copy from assets/tailwind.md — Tailwind CSS v4.1+ rules including breaking changes reference, renamed utilities, layout/spacing rules, typography, opacity modifiers, responsive design, dark mode, gradient utilities, CSS variables, container queries, and common pitfalls.
Create docs/STYLE.md:
Copy from assets/STYLE.md — Ruby/Rails style guide covering conditional returns (prefer expanded over guard clauses), methods ordering, invocation order, bang methods, visibility modifiers (indent under private), CRUD controllers, vanilla Rails approach, and async operations in jobs.
These files ensure Claude Code reads and follows the team's coding conventions automatically.
Generate JavaScript route helpers from Rails routes so the frontend can reference named routes type-safely.
bundle add js-routes
This generates route helpers that can be imported in TypeScript. See js-routes documentation for configuration.
For Inertia 3 with inertia_rails 3.19+, the recommended setup is:
@inertiajs/vitebin/dev for development SSRdata-server-renderedCreate SSR entry point at 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 })
const mod = pages[`../pages/${name}.tsx`]
if (!mod) throw new Error(`Missing Inertia page: '${name}.tsx'`)
return mod.default
},
setup: ({ App, props }) => <App {...props} />,
defaults: {
form: {
forceIndicesArrayFormatInFormData: false,
},
},
}),
)
Configure Vite for plugin-based SSR in vite.config.ts:
import inertia from '@inertiajs/vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
import RubyPlugin from 'vite-plugin-ruby'
export default defineConfig({
plugins: [
RubyPlugin(),
inertia({
ssr: 'ssr/ssr.tsx',
}),
react(),
tailwindcss(),
],
})
Do not add a manual build.rollupOptions.input override unless you have a repo-specific reason. The plugin and vite-plugin-ruby already derive the SSR entry correctly.
Enable client-side hydration in app/frontend/entrypoints/inertia.tsx:
Add imports:
import { StrictMode } from 'react'
import { createRoot, hydrateRoot } from 'react-dom/client'
Update setup function (StrictMode wraps both hydrate and CSR paths):
setup({ el, App, props }) {
if (el) {
const app = (
<StrictMode>
<App {...props} />
</StrictMode>
)
if (el.hasAttribute('data-server-rendered')) {
hydrateRoot(el, app)
} else {
createRoot(el).render(app)
}
} else {
console.error('Missing root element...')
}
}
Do not gate hydration on import.meta.env.MODE === 'production'. In dev SSR, the HTML is already server-rendered and still needs hydration.
Configure Inertia Rails with SSR in config/initializers/inertia_rails.rb:
InertiaRails.configure do |config|
config.version = ViteRuby.digest
config.encrypt_history = true
config.always_include_errors_hash = true
config.ssr_enabled = lambda {
ViteRuby.config.ssr_build_enabled || ViteRuby.instance.dev_server_running?
}
config.use_script_element_for_initial_page = true
config.use_data_inertia_head_attribute = true
# Transform snake_case props to camelCase for JavaScript frontend
config.prop_transformer = lambda do |props:|
props.deep_transform_keys { |key| key.to_s.camelize(:lower) }
end
end
Why these settings matter:
ssr_enabled lambda — enables production SSR when ssrBuildEnabled is on, and development SSR automatically when the Vite dev server is running via bin/devuse_script_element_for_initial_page + use_data_inertia_head_attribute — Inertia 3 expects <script data-page="app" type="application/json"> and head tags marked with data-inertia; without these, client and server markup divergeprop_transformer — Rails uses snake_case, JavaScript uses camelCase; this auto-converts all prop keys (e.g., created_at → createdAt) so the frontend receives idiomatic objects. Must use keyword argument |props:|, not positional |props|Do not add the old client-side defaults.future block for these flags. In Inertia 3 those client futures are no longer the right upgrade path; keep the compatibility settings in Rails.
Create app/views/inertia.html.erb:
<%= inertia_root %>
Verify the application layout still includes the Inertia + Vite tags
At minimum, app/views/layouts/application.html.erb should include:
<%= vite_react_refresh_tag %>
<%= vite_client_tag %>
<%= vite_typescript_tag "inertia.tsx" %>
<%= inertia_ssr_head %>
If inertia_ssr_head is missing, SSR head tags will not be rendered. If vite_typescript_tag "inertia.tsx" is missing, the client app will not boot.
Enable SSR in Vite - Add to config/vite.json:
{
"production": {
"ssrBuildEnabled": true
}
}
TWO modifications needed to the generated Dockerfile!
Reference references/dockerfile-ssr-patterns.md for complete examples.
Modification 1: Install Node.js in BASE stage
Add AFTER "Install base packages", BEFORE bundler installation:
# 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
Modification 2: Install Bundler with Locked Version
Add AFTER Node.js installation, BEFORE "Set production environment":
# Ensure the Bundler version matches Gemfile.lock to avoid per-build upgrades.
RUN gem install bundler -v 2.7.2 -N
Important: Check Gemfile.lock bottom section for BUNDLED WITH version and update to match:
tail Gemfile.lock
# BUNDLED WITH
# 2.7.2
That's all! No changes to BUILD stage (Vite Ruby handles npm install), no changes to final stage, no EXPOSE, no CMD modifications.
Database client variations - adjust base packages only:
postgresql-client (base) and libpq-dev (build)default-mysql-client (base) and libmysqlclient-dev (build)Update config/deploy.yml with SSR and database configuration.
Reference references/kamal-ssr-deployment.md and references/complete-guide.md for detailed examples.
Key additions:
vite server with init: true and network-alias: vite_ssr optionsINERTIA_SSR_URL: http://vite_ssr:13714 to envRedis/Valkey Configuration (if requested): Use Valkey instead of Redis due to licensing concerns:
accessories:
redis:
image: valkey/valkey:9 # Use Valkey, not redis image
host: SERVER_IP
port: "127.0.0.1:6379:6379"
directories:
- redis_data:/data
env:
clear:
REDIS_URL: redis://PROJECT_NAME-redis:6379/1
Solid Queue Configuration:
env:
clear:
SOLID_QUEUE_IN_PUMA: true
--skip-solid, OMIT this variableOptional: Async Job Server (Advanced):
For dedicated job processing (alternative to SOLID_QUEUE_IN_PUMA), add a job server:
servers:
job:
hosts:
- SERVER_IP
cmd: bundle exec async-job-adapter-active_job-server
This runs jobs in a separate container for better resource isolation.
Create database initialization file at db/production.sql:
CREATE DATABASE PROJECT_NAME_production_cache;
CREATE DATABASE PROJECT_NAME_production_queue;
CREATE DATABASE PROJECT_NAME_production_cable;
Update database.yml production section:
production:
primary: &primary_production
<<: *default
host: <%= ENV["DB_HOST"] %>
database: PROJECT_NAME_production
username: PROJECT_NAME
password: <%= ENV["POSTGRES_PASSWORD"] %> # or MYSQL_ROOT_PASSWORD
Verify the setup works:
export RAILS_ENV=production
./bin/rails assets:precompile
Check for successful output:
public/vite-ssr/ssr.js existsInform the user about deployment configuration:
Configure secrets in .env (git-ignored):
POSTGRES_PASSWORD=secure-password
KAMAL_REGISTRY_PASSWORD=docker-hub-token
Update .kamal/secrets:
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
RAILS_MASTER_KEY=$(cat config/master.key)
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
Update config/deploy.yml with actual server IPs and domain
Deploy:
export $(grep -v '^#' .env | xargs)
git add . && git commit -m "Setup Inertia Rails with SSR"
kamal setup
kamal deploy
Always use these flags to avoid prompts:
# ShadcnUI initialization
npx shadcn@latest init --defaults --yes
npx shadcn@latest add COMPONENT --yes --overwrite
# Inertia installation
bin/rails generate inertia:install --no-interactive
rails assets:precompile (handles both client and SSR, plus npm install)bin/dev for SSR with HMRbin/vite build --ssr separately (redundant)bin/vite ssr during developmentnpm ci separately (Vite Ruby handles it)Web Container (Rails) ←→ vite_ssr Container (Node.js)
via http://vite_ssr:13714
Fixed hostname via network-alias: vite_ssr enables reliable connection.
pgpostgresql-clientlibpq-devpostgres:18 (latest stable)data:/var/lib/postgresql (BREAKING CHANGE in PostgreSQL 18: previous versions used data:/var/lib/postgresql/data)127.0.0.1:5432:5432POSTGRES_USER, POSTGRES_DB, POSTGRES_PASSWORDmysql2default-mysql-clientlibmysqlclient-devmysql:9.4.0 (latest Innovation) or mysql:8.4 (LTS recommended for production)127.0.0.1:3306:3306MYSQL_ROOT_HOST, MYSQL_DATABASE, MYSQL_USER, MYSQL_ROOT_PASSWORDsqlite3 (default)storage/ directory in Rails 8+# CORRECT: Mount storage directory (contains production.sqlite3)
volumes:
- "PROJECT_NAME_storage:/rails/storage"
# INCORRECT: Do not mount db directory
# volumes:
# - "PROJECT_NAME_db:/rails/db"
config/database.yml production section for actual path:production:
<<: *default
database: storage/production.sqlite3 # Note: stored in storage/ not db/
tsconfig.json and tsconfig.app.json--defaults --yes with shadcn, --no-interactive with inertia:installnetwork-alias: vite_ssr"host": "127.0.0.1", connections to 127.0.0.1 failcreateRoot() in dev when SSR HTML is present; hydrate when #app has data-server-rendereduse_script_element_for_initial_page = true and use_data_inertia_head_attribute = true, Inertia 3 client and Rails markup divergepackage.json must use "build": "vite build && vite build --ssr"inertia_ssr_head in layout - SSR head tags and data-inertia metadata will be missing from HTMLhost: <%= ENV["DB_HOST"] %> and password for production/rails/storage (not /rails/db) since Rails 8 stores SQLite in storage/ directoryvalkey/valkey:9 image to avoid Redis licensing concernsGemfile.lock to prevent cache invalidationlambda { |props:| ... } or ->(props:) { ... }, NOT positional proc { |props| ... }. inertia_rails 3.17+ calls prop_transformer(props: props) with a keyword arg — using a positional param wraps props in a { props: { ... } } key, making all props inaccessible on the frontendconfig/vite.json has "ssrBuildEnabled": trueapp/frontend/ssr/ssr.tsx existsvite.config.ts includes inertia({ ssr: 'ssr/ssr.tsx' })config.ssr_enabled checks ViteRuby.instance.dev_server_running?vite server has network-alias: vite_ssr in deploy.ymlINERTIA_SSR_URL: http://vite_ssr:13714 in envcurl http://localhost:3000/ and verify:<script data-page="app" type="application/json"><div data-server-rendered="true" id="app">data-inertiaInertia SSR dev endpoint: /__inertia_ssrapp/views/layouts/application.html.erb includes <%= inertia_ssr_head %>config.use_data_inertia_head_attribute = trueDB_HOST matches accessory service name (e.g., PROJECT_NAME-db)kamal accessory details db.kamal/secrets and config/database.yml@/ prefix: import { Button } from '@/components/ui/button'complete-guide.md - Complete 778-line implementation guide with all variationsdockerfile-ssr-patterns.md - Dockerfile modifications for PostgreSQL/MySQL/SQLitekamal-ssr-deployment.md - Kamal SSR architecture and deployment examplesssr-entry.tsx - SSR server entry point templatedeploy-postgres.yml - PostgreSQL deployment configurationdeploy-mysql.yml - MySQL deployment configurationdeploy-sqlite.yml - SQLite deployment configurationCLAUDE.md - Project-level Claude Code instructions (write to CLAUDE.md at project root)tailwind.md - Tailwind CSS v4.1+ rules and best practices (write to docs/tailwind.md)STYLE.md - Ruby/Rails coding style guide (write to docs/STYLE.md)Use these references when detailed examples are needed or when helping users with specific database configurations.