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.js
css:
line from Procfile.dev
postcss.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.