Si vienes del mundo de React, Next.js o Go, sabes que mantener la sincronía entre el frontend y el backend es una de las mayores fuentes de bugs. Rust, con el framework Leptos, propone una alternativa interesante: isomorfismo real. Un mismo lenguaje, tipos compartidos y una única definición central de los datos y reglas del sistema.
Esta filosofía no es nueva para quienes venimos de C#, donde compartir librerías de modelos entre el servidor y el cliente es una práctica habitual para mantener la consistencia de los datos. Sin embargo, Leptos aplica este enfoque de forma especialmente natural al desarrollo web moderno, permitiendo que esa “fuente única de verdad” se compile tanto en el binario del servidor como en el WebAssembly del navegador.
El resultado es un modelo en el que los contratos entre frontend y backend se expresan directamente en el código compartido, reduciendo la necesidad de definiciones externas y disminuyendo los errores derivados de desalineaciones entre capas.
En este artículo vamos a ver cómo estructurar una aplicación full-stack en Leptos con una arquitectura clara, pensada para ser escalable y mantenible, usando un ejemplo didáctico orientado a introducir los conceptos principales del framework.
1. El Corazón: La Capa de Dominio (/domain)
En Leptos, el dominio puede ser isomórfico. Esto significa que el código dentro de esta carpeta se compila tanto para el servidor (nativo) como para el cliente (WebAssembly).
Aquí definimos nuestros structs y enums. Al usar la crate validator, podemos expresar reglas de negocio en un solo lugar:
// domain/user.rs
#[derive(Serialize, Deserialize, Validate)]
pub struct LoginCredentials {
#[validate(length(min = 3, message = "Usuario debe tener al menos 3 caracteres"))]
pub username: String,
#[validate(length(min = 6, message = "Contraseña debe tener al menos 6 caracteres"))]
pub password: String,
}
¿Por qué esto es clave?
Porque la misma validación se ejecuta en el frontend antes de realizar la request, evitando llamadas innecesarias, y vuelve a ejecutarse en el backend como validación final. Esto ayuda a reducir la duplicación de reglas entre capas y a mantener la consistencia del modelo.
No elimina toda la lógica específica de cada lado (por ejemplo, cómo se muestran los errores en la UI), pero sí centraliza la definición de las reglas principales.
Estado Global Tipado
Además de las validaciones, el dominio también define el estado de autenticación de forma explícita:
// domain/state.rs
pub enum AuthState {
Loading,
Authenticated(User),
Unauthenticated,
}
Este enum ayuda a evitar estados inválidos. No puedes representar un usuario autenticado sin datos, ni olvidarte de manejar el estado de carga. El compilador te obliga a ser exhaustivo, lo que reduce errores a medida que la aplicación crece.
2. La Fortaleza: El Servidor (/server)
Aquí reside la lógica sensible: conexiones a bases de datos (SQLite/SQLx), hashing de contraseñas (bcrypt) y gestión de sesiones.
Gracias a las Server Functions de Leptos, no es necesario escribir una API REST manualmente. Simplemente defines una función y Leptos se encarga del mecanismo de llamada entre cliente y servidor:
#[server(Login, "/api")]
pub async fn login(credentials: LoginCredentials) -> Result<User, ServerFnError> {
// Validación en servidor (misma lógica que el cliente)
validate(&credentials)?;
// Consulta a la base de datos
let user = sqlx::query_as("SELECT * FROM users WHERE username = ?")
.bind(&credentials.username)
.fetch_optional(pool)
.await?;
// Verificación con bcrypt
bcrypt::verify(&credentials.password, &user.password_hash)?;
// Creación de sesión con UUID
let session_id = Uuid::new_v4().to_string();
// Cookie HttpOnly con SameSite=Lax
let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id))
.http_only(true)
.same_site(SameSite::Lax)
.max_age(Duration::days(7))
.build();
Ok(user)
}
Este ejemplo está simplificado a propósito para ilustrar el flujo general. Detalles como persistencia avanzada de sesiones, manejo fino de errores o protecciones adicionales se dejan fuera para mantener el foco en la arquitectura y en el modelo mental de Leptos.
Seguridad por Defecto
Al usar la directiva [cfg(feature = «ssr»)], el código del servidor solo se compila cuando la aplicación se ejecuta en modo SSR. El resultado es que la lógica sensible y las dependencias del backend no forman parte del bundle WebAssembly que se envía al navegador.
Además, la gestión de sesiones utiliza:
- Cookies HttpOnly: JavaScript no puede acceder a ellas
- SameSite=Lax: Protección básica contra CSRF
- Expiración automática: Las sesiones tienen un tiempo de vida definido
3. La Interfaz: Frontend Modular (/frontend)
Para evitar que la UI se convierta en un conjunto desordenado de archivos, el frontend se organiza por features, no por categorías genéricas:
frontend/
├── auth/
├── users/
├── layouts/
└── state/
Este enfoque facilita el crecimiento del proyecto y ayuda a que cada parte tenga responsabilidades claras, algo especialmente útil cuando el código base empieza a crecer.
Contexto Global con Signals
El estado de autenticación se gestiona mediante un Context Provider que envuelve toda la aplicación:
#[component]
pub fn AuthProvider(children: Children) -> impl IntoView {
let state = RwSignal::new(AuthState::Loading);
let login_action = Action::new(|credentials| async {
login(credentials).await
});
// Efecto reactivo: cuando login_action cambia, actualiza el estado
Effect::new(move |_| {
if let Some(Ok(user)) = login_action.value().get() {
state.set(AuthState::Authenticated(user));
}
});
provide_context(AuthContext { state, login_action });
children()
}
Cualquier componente puede acceder al estado con use_auth(), sin necesidad de prop drilling.
4. El Flujo de Trabajo (Workflow)
Uno de los puntos fuertes de Leptos es su reactividad de grano fino. En lugar de re-renderizar componentes completos, el framework actualiza directamente los nodos del DOM que dependen de un valor concreto.
Ejemplo: Flujo de Login
1. El usuario envía el LoginForm
- Validación en cliente con validator
- Feedback inmediato si falla
2. Una Action de Leptos llama a la Server Function
- Serialización automática
- POST a /api/login
3. El servidor procesa la lógica
- Consulta SQLite con sqlx
- Verifica el hash con bcrypt
- Crea sesión y cookie
4. La UI reacciona actualizando el estado global
- El Effect detecta el cambio
- Se actualiza AuthState
- Los componentes dependientes reaccionan automáticamente
Ventajas sobre React/Next.js
| Aspecto | React / Next.js | Leptos |
|---|---|---|
| Validación | Normalmente duplicada | Definición central reutilizable |
| Tipado | TypeScript | Rust (compile-time) |
| Re-renders | Por componente | Por nodo dependiente |
| Integración FE/BE | Contratos implícitos | Tipos compartidos |
| Errores de integración | Detectados en runtime | Detectados antes de compilar |
No se trata de que un enfoque sea universalmente mejor que otro, sino de que Leptos reduce una clase concreta de problemas relacionados con la integración entre frontend y backend.
5. Configuración de Features: Un Binario, Dos Mundos
El Cargo.toml define dos features mutuamente exclusivas:
[features]
ssr = [
"dep:axum",
"dep:sqlx",
"dep:bcrypt",
# ... dependencias del servidor
]
hydrate = [
"dep:wasm-bindgen",
"dep:console_error_panic_hook",
# ... dependencias del cliente
]
Al ejecutar cargo leptos watch:
- El servidor se compila con –features ssr
- El cliente con –features hydrate
Esto refuerza la separación entre cliente y servidor sin duplicar código ni modelos.
¿Podemos usar Rust en la Web?
Elegir Rust y Leptos no es solo una cuestión de rendimiento. Es un cambio de modelo mental. El compilador actúa como una red de seguridad constante que ayuda a detectar inconsistencias entre capas antes de que lleguen a producción.
La arquitectura Domain–Server–Frontend que se muestra aquí no es la forma más rápida de hacer un “Hola Mundo”, pero sí una base sólida para entender cómo Rust puede simplificar el desarrollo full-stack al reducir una gran parte de los errores de integración.
Hice este corto tutorial para introducirlos en el tema, el objetivo no es cubrir todos los edge cases, sino mostrar por qué este enfoque resulta atractivo y cómo pensar una aplicación web full-stack desde Rust. Si te interesa revisar el código de la aplicación, puedes revisar el repositorio con el código en GitHub.
Sigamos codificando.