rails new app_name -T -d postgresql --css=tailwind --javascript=esbuild cd app_name rails db:create
yarn add postcss-import touch postcss.config.js
postcss.config.js
module.exports = {
  plugins: [
    require('postcss-import'),
    require('tailwindcss'),
    require('autoprefixer'),
  ],
}
app/assets/stylesheets/application.tailwind.css
@tailwind base; @tailwind components; @tailwind utilities;
yarn add @tailwindcss/forms touch tailwind.config.js
tailwind.config.js
module.exports = {
  mode: 'jit',
  content: [
    './app/views/**/*.html.erb',
    './app/helpers/**/*.rb',
    './app/assets/stylesheets/**/*.css',
    './app/javascript/**/*.js',
  ],
  theme: {
    extend: {},
  },
  plugins: [require('@tailwindcss/forms')],
}
yarn add chokidar -D touch esbuild.config.js
esbuild.config.js
!/usr/bin/env node const esbuild = require('esbuild') const path = require('path') const { execSync } = require('child_process') try { execSync('kill -9 $(lsof -ti :8082)', { stdio: 'ignore' }) } catch (e) {} const entryPoints = ['application.js'] const watchDirectories = [ path.join(process.cwd(), 'app/javascript'), path.join(process.cwd(), 'app/views'), path.join(process.cwd(), 'app/assets/stylesheets'), ] const config = { absWorkingDir: path.join(process.cwd(), 'app/javascript'), bundle: true, entryPoints, outdir: path.join(process.cwd(), 'app/assets/builds'), sourcemap: true, } async function rebuild() { const chokidar = require('chokidar') const http = require('http') const clients = [] http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Access-Control-Allow-Origin': '*', Connection: 'keep-alive', }) res.write('\n') clients.push(res) }).listen(8082, () => { console.log('Reload server running at http://localhost:8082') }) const ctx = await esbuild.context({ ...config, banner: { js: ' (() => new EventSource("http://localhost:8082").onmessage = () => location.reload())();' } }) process.on('SIGINT', async () => { console.log('Stopping watcher...') await ctx.dispose() process.exit() }) chokidar.watch(watchDirectories).on('all', (event, changedPath) => { console.log(`[WATCH] ${event} at ${changedPath}`) if (changedPath.includes('javascript')) { ctx.rebuild().then(() => console.log('JS rebuilt')).catch(console.error) } clients.forEach(res => res.write('data: update\n\n')) clients.length = 0 }) } if (process.argv.includes('--watch')) { rebuild() } else { esbuild.build({ ...config, minify: process.env.RAILS_ENV === 'production', }).catch(() => process.exit(1)) }
package.json
{
  "scripts": {
    "build": "node esbuild.config.js",
    "build:css": "tailwindcss --postcss -i ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/application.css"
  }
}
Procfile.dev
web: bin/rails server -p 3000 js: node esbuild.config.js --watch css: yarn build:css --watch
app/views/layouts/application.html.erb
<head> <title>My App</title> <meta name="viewport" content="width=device-width,initial-scale=1"> <%= csrf_meta_tags %> <%= csp_meta_tag %> <%= action_cable_meta_tag %> <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %> </head>
bundle add stimulus_reflex bundle add redis-session-store yarn add stimulus_reflex yarn add mrujs rails dev:cache
"@hotwired/stimulus": "^3.2.2", "stimulus": "npm:@hotwired/stimulus", "mrujs": "^0.8.4"
Then:
yarn install
mkdir -p app/javascript/channels touch app/javascript/channels/consumer.js
app/javascript/channels/consumer.js
import { createConsumer } from "@rails/actioncable" export default createConsumer()
;(() => (new EventSource('http://localhost:8082').onmessage = () => location.reload()))() import "@hotwired/turbo-rails" import "./controllers" import consumer from './channels/consumer' import CableReady from "cable_ready" import mrujs from "mrujs" import { CableCar } from "mrujs/plugins" mrujs.start({ plugins: [new CableCar(CableReady)] })
touch app/reflexes/application_reflex.rb
app/reflexes/application_reflex.rb
class ApplicationReflex < StimulusReflex::Reflex end
app/javascript/controllers/application.js
import { Application } from "@hotwired/stimulus" const application = Application.start() application.debug = false window.Stimulus = application export { application }
app/javascript/controllers/application_controller.js
import { Controller } from "@hotwired/stimulus" export default class extends Controller { connect() {} }
app/javascript/controllers/index.js
import { application } from './application' import applicationController from './application_controller' import StimulusReflex from 'stimulus_reflex' import HelloController from './hello_controller' application.register('hello', HelloController) StimulusReflex.initialize(application, { applicationController, isolate: true }) StimulusReflex.debug = true
Replace the default caching logic with:
config.action_controller.perform_caching = true config.action_controller.enable_fragment_cache_logging = true config.cache_store = :redis_cache_store, { url: ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } } config.session_store :redis_session_store, key: "_sessions_development", compress: true, pool_size: 5, expire_after: 1.year
development: adapter: redis url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> channel_prefix: your_app_development
Inside <head> ensure:
<%= action_cable_meta_tag %>
If you want to use UUIDs instead of integer primary keys by default:
config.generators do |g| g.orm :active_record, primary_key_type: :uuid end
rails g migration EnableUuid
class EnableUuid < ActiveRecord::Migration[7.0] def change enable_extension 'pgcrypto' unless extension_enabled?('pgcrypto') end end
Connect as a superuser:
sudo -u postgres psql -d your_app_development
Then run:
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; \q
And finish with:
rails db:migrate
Create this file:
touch config/initializers/generators.rb
Add:
Rails.application.config.generators do |g| g.orm :active_record, primary_key_type: :uuid end
tailwindcss-rails Gem Instead of Manual SetupIf you want a simpler setup and don’t need full control via PostCSS and esbuild, you can use the official gem:
bundle add tailwindcss-rails
yarn remove tailwindcss autoprefixer postcss postcss-import chokidar @tailwindcss/forms
rails tailwindcss:install
This will:
app/assets/stylesheets/application.tailwind.css.esbuild.config.jscss: line from Procfile.devpostcss.config.js and manual Tailwind setupThis is a cleaner and faster way to integrate Tailwind in simple Rails projects. You lose some flexibility (like plugins or watching .erb file changes), but it's production-ready out of the box.
Use this method only if you don’t need:
Start new app
rails new app_name -T -d postgresql --css=tailwind --javascript=esbuild
cd app_name
rails db:create
yarn add postcss-import
touch postcss.config.js
postcss.config.js
module.exports = {
  plugins: [
    require('postcss-import'),
    require('tailwindcss'),
    require('autoprefixer'),
  ],
}
app/assets/stylesheets/application.tailwind.css
@tailwind base; @tailwind components; @tailwind utilities;
Add tailwind forms:
yarn add @tailwindcss/forms
if not exist: touch tailwind.config.js
tailwind.config.js
module.exports = {
  mode: 'jit',
  content: [
    './app/views/**/*.html.erb',
    './app/helpers/**/*.rb',
    './app/assets/stylesheets/**/*.css',
    './app/javascript/**/*.js',
  ],
  theme: {
    extend: {},
  },
  plugins: [require('@tailwindcss/forms')],
}
Add chokidar to enable watching and automatically refreshing.
yarn add chokidar -D
touch esbuild.config.js
esbuild.config.js
#!/usr/bin/env node
const esbuild = require('esbuild')
const path = require('path')
const { execSync } = require('child_process')
// 🧹 Kill any existing process using port 8082 (reload server)
try {
  execSync('kill -9 $(lsof -ti :8082)', { stdio: 'ignore' })
} catch (e) {
  // Port wasn't in use --- that's fine
}
// ✅ Entry points
const entryPoints = ['application.js']
// ✅ Absolute paths to watch
const watchDirectories = [
  path.join(process.cwd(), 'app/javascript'),
  path.join(process.cwd(), 'app/views'),
  path.join(process.cwd(), 'app/assets/stylesheets'),
]
// ✅ ESBuild base config
const config = {
  absWorkingDir: path.join(process.cwd(), 'app/javascript'),
  bundle: true,
  entryPoints: entryPoints,
  outdir: path.join(process.cwd(), 'app/assets/builds'),
  sourcemap: true,
}
async function rebuild() {
  const chokidar = require('chokidar')
  const http = require('http')
  const clients = []
  // 🛰️ Create live reload server
  http
    .createServer((req, res) => {
      res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Access-Control-Allow-Origin': '*',
        Connection: 'keep-alive',
      })
      res.write('\n') // Keeps connection open for EventSource
      clients.push(res)
    })
    .listen(8082, () => {
      console.log('📡 Reload server running at http://localhost:8082')
    })
  // 🔁 Watch-ready ESBuild context
  const ctx = await esbuild.context({
    ...config,
    banner: {
      js: ' (() => new EventSource("http://localhost:8082").onmessage = () => location.reload())();',
    },
  })
  // 🧹 Clean exit on Ctrl+C
  process.on('SIGINT', async () => {
    console.log('\n🛑 Stopping watcher...')
    await ctx.dispose()
    process.exit()
  })
  // 👁️ Watch for changes
  chokidar.watch(watchDirectories).on('all', (event, changedPath) => {
    console.log(`[WATCH] ${event} at ${changedPath}`)
    if (changedPath.includes('javascript')) {
      ctx
        .rebuild()
        .then(() => console.log('✅ JS rebuilt'))
        .catch((err) => console.error('❌ Rebuild failed', err))
    }
    clients.forEach((res) => res.write('data: update\n\n'))
    clients.length = 0
  })
}
// 🏁 Entry point
if (process.argv.includes('--watch')) {
  rebuild()
} else {
  esbuild
    .build({
      ...config,
      minify: process.env.RAILS_ENV === 'production',
    })
    .catch(() => process.exit(1))
}
bin/dev script
package.json
  "scripts": {
    "build": "node esbuild.config.js",
    "build:css": "tailwindcss --postcss -i ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/application.css"
  },
Procfile.dev
web: bin/rails server -p 3000 js: node esbuild.config.js --watch css: yarn build:css --watch
application.html.erb
<!DOCTYPE html> <html> <head> <title>title</title> <meta name="viewport" content="width=device-width,initial-scale=1"> <%= csrf_meta_tags %> <%= csp_meta_tag %> <%= action_cable_meta_tag %> <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %> </head> <body> <main class="container mx-auto mt-28 px-5 flex"> <%= yield %> </main> </body> </html>
bundle add stimulus_reflex bundle add redis-session-store yarn add stimulus_reflex rails dev:cache # caching needs to be enabled
✅ Use this format to generate a reflex:
rails generate stimulus_reflex MyFeature
This will create:
app/reflexes/my_feature_reflex.rb (the backend Ruby class) app/javascript/controllers/my_feature_controller.js (the frontend Stimulus controller)
Wiring them both together
✅ Refactor app/javascript/application.js
// app/javascript/application.js
// 🔁 Hot reload support (EventSource)
;(() =>
  (new EventSource('http://localhost:8082').onmessage = () =>
    location.reload()))()
// ⚡ Turbo (Hotwire)
import '@hotwired/turbo-rails'
// 🚀 Load all Stimulus controllers (auto-registered in controllers/index.js)
import './controllers'
// ✅ Optional: test if JS is running
console.log('JavaScript is working!')
document.addEventListener('DOMContentLoaded', () => {
  const btn = document.getElementById('test-button')
  if (btn) {
    btn.addEventListener('click', () => {
      alert('🟢 JS works!')
    })
  }
})
✅ app/javascript/controllers/application.js:
// app/javascript/controllers/application.js
import { Application } from "@hotwired/stimulus"
const application = Application.start()
application.debug = false
window.Stimulus = application
export { application }
✅ app/javascript/controllers/index.js
// app/javascript/controllers/index.js
// This file is auto-generated by ./bin/rails stimulus:manifest:update
// Run that command whenever you add a new controller or create them with
// ./bin/rails generate stimulus controllerName
import { application } from './application'
import applicationController from './application_controller'
import StimulusReflex from 'stimulus_reflex'
// Manual imports (add new controllers here)
import HelloController from './hello_controller'
application.register('hello', HelloController)
// ✅ Initialize StimulusReflex
StimulusReflex.initialize(application, { applicationController, isolate: true })
StimulusReflex.debug = true
✅ app/javascript/controllers/application_controller.js
// app/javascript/controllers/application_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
  connect() {
    // Optional hook for Reflex or Turbo interactions
  }
}
config/environments/development.rb
swap:
Rails.root.join("tmp/caching-dev.txt").exist?
    config.action_controller.perform_caching = true
    config.action_controller.enable_fragment_cache_logging = true
    config.cache_store = :memory_store
    config.public_file_server.headers = {
      "Cache-Control" => "public, max-age=#{2.days.to_i}"
    }
  else
    config.action_controller.perform_caching = false
    config.cache_store = :null_store
  end
to this:
config.action_controller.perform_caching = true
config.action_controller.enable_fragment_cache_logging = true
config.cache_store = :redis_cache_store, {
  url: ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" }
}
config.session_store :redis_session_store,
  key: "_sessions_development",
  compress: true,
  pool_size: 5,
  expire_after: 1.year
Configure ActionCable to use the Redis adapter in development mode in config/cable.yml
development:
  adapter: redis
  url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
  channel_prefix: your_application_development
app/views/layouts/application.html.erb
<head> <%= csrf_meta_tags %> <%= csp_meta_tag %> <%= action_cable_meta_tag %> </head>
✅ ActionCable (needed for Reflex, CableReady, Turbo Streams)
mkdir -p app/javascript/channels
touch app/javascript/channels/consumer.js
// app/javascript/channels/consumer.js
import { createConsumer } from "@rails/actioncable"
export default createConsumer()
✅ Step: Fix package.json Stimulus alias
Update package.json:
"@hotwired/stimulus": "^3.2.2", "stimulus": "npm:@hotwired/stimulus"
yarn install
This alias ensures stimulus_reflex uses the correct Stimulus core.
✅ Step: Install mrujs
yarn add mrujs
✅ Step 5: Update application.js for Reflex, CableReady, Mrujs
import "@hotwired/turbo-rails"
import "./controllers"
import consumer from './channels/consumer'
import CableReady from "cable_ready"
import mrujs from "mrujs"
import { CableCar } from "mrujs/plugins"
mrujs.start({
  plugins: [new CableCar(CableReady)]
})
touch app/reflexes/application_reflex.rb
app/reflexes/application_reflex.rb
app/reflexes/application_reflex.rb
class ApplicationReflex < StimulusReflex::Reflex
end
✅ Optional Step: Use UUIDs for primary keys If starting fresh, edit config/application.rb:
config.generators do |g|
g.orm :active_record, primary_key_type: :uuid
end
Use uuids by default
rails g migration EnableUUID
migration file:
class EnableUuid < ActiveRecord::Migration[7.0]
def change
enable_extension 'pgcrypto' unless extension_enabled?('pgcrypto')
end
end
In case of fail migration:
sudo -u postgres psql -d app_name_development
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
\q
rails db:migrate
touch config/initializers/generators.rb
config/initializers/generators.rb
Rails.application.config.generators do |g|
g.orm :active_record, primary_key_type: :uuid
end
To replay you need to login. Don't have an account? Sign up for one.