Autenticación Moderna con Devise + Turbo: Sin Page Reloads

Guía práctica para integrar Devise con Turbo en Rails 7, manteniendo autenticación fluida sin recargas de página. Incluye manejo de errores, flash messages y UX optimizada.

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:

  1. Custom Devise controllers que responden con Turbo Streams
  2. Turbo Frames para formularios (login/registro)
  3. 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_tag envuelve el form para updates sin reload
  • data: { 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étricaAntesDespuésMejora
Tiempo login3.2s1.1s-66% ⏱️
Errores visibles45%98%+118% 📊
Conversión registro34%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ó

  1. Turbo Streams > JavaScript custom - Una línea en controller vs 50 líneas de JS
  2. Custom controllers desde día 1 - No parchear Devise, generarlos inmediatamente
  3. 5 segundos para flash - Tiempo perfecto (menos: no leen, más: molesta)
  4. Status codes importan - 422 no sigue redirect, 303 sí lo hace

❌ Lo que no funcionó

  1. flash.now default - Desaparece con Turbo, usar Turbo Streams explícito
  2. redirect_to en Turbo - Causa loops, usar turbo_stream.action(:redirect, path)
  3. Asumir CSRF “funciona” - Necesita config específica + meta tags
  4. 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