Setup new rails app with tailwind

by msypniewski511 in Ruby for Rails

Start new app

rails new app_name -T -d postgresql --css=tailwind --javascript=esbuild
cd app_name
rails db:create

Configure Tailwind

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')],
}

ESBuild config

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))
}

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

Layout Head Tags

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>

StimulusReflex, CableReady, Mrujs Setup

Install dependencies

bundle add stimulus_reflex
bundle add redis-session-store
yarn add stimulus_reflex
yarn add mrujs
rails dev:cache

package.json

"@hotwired/stimulus": "^3.2.2",
"stimulus": "npm:@hotwired/stimulus",
"mrujs": "^0.8.4"

Then:

yarn install

Create ActionCable consumer

mkdir -p app/javascript/channels
touch app/javascript/channels/consumer.js

app/javascript/channels/consumer.js

import { createConsumer } from "@rails/actioncable"
export default createConsumer()

app/javascript/application.js

;(() => (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)]
})

StimulusReflex setup

Create Reflex base class

touch app/reflexes/application_reflex.rb

app/reflexes/application_reflex.rb

class ApplicationReflex < StimulusReflex::Reflex
end

Stimulus Controller setup

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

Redis and ActionCable configuration

config/environments/development.rb

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

config/cable.yml

development:
  adapter: redis
  url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
  channel_prefix: your_app_development

app/views/layouts/application.html.erb

Inside <head> ensure:

<%= action_cable_meta_tag %>

Enable UUIDs for ActiveRecord (Optional)

If you want to use UUIDs instead of integer primary keys by default:

config/application.rb

config.generators do |g|
  g.orm :active_record, primary_key_type: :uuid
end

Generate migration to enable the pgcrypto extension:

rails g migration EnableUuid

db/migrate/xxxxxxxxxxxxxx_enable_uuid.rb

class EnableUuid < ActiveRecord::Migration[7.0]
  def change
    enable_extension 'pgcrypto' unless extension_enabled?('pgcrypto')
  end
end

If you get a permission error on migration:

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

(Optional) Force UUIDs for all future generators

Create this file:

touch config/initializers/generators.rb

Add:

Rails.application.config.generators do |g|
  g.orm :active_record, primary_key_type: :uuid
end

Optional: Use tailwindcss-rails Gem Instead of Manual Setup

If you want a simpler setup and don’t need full control via PostCSS and esbuild, you can use the official gem:

Install the gem:

bundle add tailwindcss-rails

Remove previously added npm packages:

yarn remove tailwindcss autoprefixer postcss postcss-import chokidar @tailwindcss/forms

Install Tailwind via the gem:

rails tailwindcss:install

This will:

  • Set up Tailwind with the default config.
  • Add it to your asset pipeline.
  • Generate a working app/assets/stylesheets/application.tailwind.css.

Remove redundant manual files:

  • Delete esbuild.config.js
  • Remove css: line from Procfile.dev
  • Clean up postcss.config.js and manual Tailwind setup

Summary

This 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:

  • Hot module reload with chokidar
  • Custom ESBuild banner injection
  • Tailwind plugin extensions from npm

My CHECKED

Start new app

rails new app_name -T  -d postgresql --css=tailwind --javascript=esbuild

cd app_name

rails db:create

Configure Tailwind

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')],

}

ESBuild config

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>

CableReady, StimulusReflex, and Mrujs

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

0 Replies


Leave a replay

To replay you need to login. Don't have an account? Sign up for one.