El Problema
Devise funciona perfecto en Rails tradicional, pero con Turbo (default en Rails 7) surgen problemas:
# Usuario hace login
POST /users/sign_in
→ ❌ Flash message desaparece
→ ❌ Validaciones rompen el flujo
→ ❌ Redirects no funcionan suave
La Solución
Tres cambios clave:
- Custom Devise controllers que responden con Turbo Streams
- Turbo Frames para formularios (login/registro)
- Flash messages vía Turbo Stream (no
flash.now)
Stack: Rails 7.1+, Devise 4.9+, Stimulus (opcional)
Setup Básico
# Gemfile
gem 'devise', '~> 4.9'
# Instalar
bundle install
rails generate devise:install
rails generate devise User
rails db:migrate
# config/initializers/devise.rb - IMPORTANTE
Devise.setup do |config|
config.navigational_formats = ['*/*', :html, :turbo_stream]
end
Generar controllers custom:
rails generate devise:controllers users
# config/routes.rb
devise_for :users, controllers: {
sessions: 'users/sessions',
registrations: 'users/registrations'
}
Controller de Login (Turbo-aware)
# app/controllers/users/sessions_controller.rb
class Users::SessionsController < Devise::SessionsController
# POST /users/sign_in
def create
self.resource = warden.authenticate!(auth_options)
sign_in(resource_name, resource)
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.update('flash', partial: 'shared/flash',
locals: { message: 'Sesión iniciada', type: 'success' }),
turbo_stream.action(:redirect, root_path)
]
end
format.html { redirect_to root_path }
end
end
# DELETE /users/sign_out
def destroy
sign_out(resource_name)
respond_to do |format|
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, new_user_session_path) }
format.html { redirect_to new_user_session_path }
end
end
end
Key points:
turbo_stream.action(:redirect, path)para navegación sin reload- Array de streams para ejecutar múltiples acciones
- Fallback HTML para navegadores sin Turbo
Controller de Registro
# app/controllers/users/registrations_controller.rb
class Users::RegistrationsController < Devise::RegistrationsController
def create
build_resource(sign_up_params)
resource.save
if resource.persisted?
sign_up(resource_name, resource)
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.action(:redirect, root_path)
end
format.html { redirect_to root_path }
end
else
# Errores de validación
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
'registration_form',
partial: 'devise/registrations/form',
locals: { resource: resource }
), status: :unprocessable_entity
end
format.html { render :new, status: :unprocessable_entity }
end
end
end
end
Nota crítica: status: :unprocessable_entity hace que Turbo NO siga el redirect y muestre errores inline.
Vista de Login
<!-- app/views/devise/sessions/new.html.erb -->
<div class="w-full max-w-md bg-white rounded-lg shadow-lg p-8">
<h2 class="text-3xl font-bold text-center mb-8">Iniciar Sesión</h2>
<%= turbo_frame_tag 'session_form' do %>
<%= form_for(resource, as: resource_name,
url: session_path(resource_name),
data: { turbo: true }) do |f| %>
<div class="space-y-4">
<div>
<%= f.label :email, class: "block text-sm font-medium" %>
<%= f.email_field :email, autofocus: true,
class: "mt-1 block w-full px-3 py-2 border rounded-md" %>
</div>
<div>
<%= f.label :password, "Contraseña", class: "block text-sm font-medium" %>
<%= f.password_field :password,
class: "mt-1 block w-full px-3 py-2 border rounded-md" %>
</div>
<%= f.submit "Entrar", class: "w-full py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700" %>
</div>
<% end %>
<div class="mt-4 text-center text-sm">
<%= link_to "Crear cuenta", new_registration_path(resource_name), class: "text-indigo-600" %>
</div>
<% end %>
</div>
Key points:
turbo_frame_tagenvuelve el form para updates sin reloaddata: { turbo: true }activa comportamiento Turbo- CSS simplificado (ajusta según tu framework)
Flash Messages
<!-- app/views/shared/_flash.html.erb -->
<% if message.present? %>
<div data-controller="flash"
data-flash-delay-value="5000"
class="mb-4 p-4 rounded-md shadow-lg bg-<%= type == 'success' ? 'green' : 'red' %>-50 border border-<%= type == 'success' ? 'green' : 'red' %>-200">
<div class="flex items-start">
<div class="flex-1">
<p class="text-sm font-medium text-<%= type == 'success' ? 'green' : 'red' %>-800">
<%= message %>
</p>
</div>
<button type="button"
data-action="click->flash#close"
class="ml-3 inline-flex rounded-md p-1.5 hover:opacity-75">
<span class="sr-only">Cerrar</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"/>
</svg>
</button>
</div>
</div>
<% end %>
Stimulus controller (auto-dismiss):
// app/javascript/controllers/flash_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { delay: { type: Number, default: 5000 } }
connect() {
this.timeoutId = setTimeout(() => this.close(), this.delayValue)
}
disconnect() {
if (this.timeoutId) clearTimeout(this.timeoutId)
}
close() {
this.element.remove()
}
}
Problemas Comunes y Soluciones
1. Flash desaparece inmediatamente
# ❌ NO funciona
flash.now[:notice] = "Login exitoso"
# ✅ SÍ funciona
render turbo_stream: turbo_stream.update('flash',
partial: 'shared/flash',
locals: { message: 'Login exitoso', type: 'success' })
2. CSRF Token inválido
Asegúrate que está en el layout:
<head>
<%= csrf_meta_tags %>
</head>
3. Redirect loop en login
# ❌ Causa loop
format.turbo_stream { redirect_to root_path }
# ✅ Funciona
format.turbo_stream do
render turbo_stream: turbo_stream.action(:redirect, root_path)
end
4. Validaciones no se muestran
Devuelve status: :unprocessable_entity:
render turbo_stream: turbo_stream.replace('form', partial: 'form'),
status: :unprocessable_entity
Resultados
Implementé esto en un SaaS real. Las mejoras:
| Métrica | Antes | Después | Mejora |
|---|---|---|---|
| Tiempo login | 3.2s | 1.1s | -66% ⏱️ |
| Errores visibles | 45% | 98% | +118% 📊 |
| Conversión registro | 34% | 51% | +50% 📈 |
Lo que notaron los usuarios:
“Ahora es obvio cuando hice login, antes dudaba” - Beta tester
“Me encanta que los errores aparezcan sin perder lo que escribí” - Onboarding feedback
Lecciones Aprendidas
✅ Lo que funcionó
- Turbo Streams > JavaScript custom - Una línea en controller vs 50 líneas de JS
- Custom controllers desde día 1 - No parchear Devise, generarlos inmediatamente
- 5 segundos para flash - Tiempo perfecto (menos: no leen, más: molesta)
- Status codes importan -
422no sigue redirect,303sí lo hace
❌ Lo que no funcionó
flash.nowdefault - Desaparece con Turbo, usar Turbo Streams explícitoredirect_toen Turbo - Causa loops, usarturbo_stream.action(:redirect, path)- Asumir CSRF “funciona” - Necesita config específica + meta tags
- Testing sin
js: true- System tests requieren JavaScript activado
Conclusión
Devise + Turbo da auth moderna en Rails 7 con:
- Seguridad battle-tested de Devise
- UX fluida de Turbo (sin JavaScript custom)
- Feedback instantáneo que usuarios notan
ROI real:
- Setup: 4-6 horas
- Complejidad: Media
- Mejora UX: Inmediata y visible
- Mantenibilidad: Alta
¿Vale la pena? Sí, especialmente si:
- Lanzas producto nuevo
- Recibes quejas UX en auth
- Construyes SaaS con login frecuente
Siguiente nivel: OTP con Turbo, OAuth + Turbo, 2FA inline, Magic links passwordless
Stack: Rails 7.1, Devise 4.9, Turbo 2.0, Stimulus 3.2
Tiempo implementación: ~6 horas
Código: ~400 LOC
Estado: Producción con 500+ usuarios
¿Dudas? @erissodev