Un formulario es la pieza donde más se nota la diferencia entre una web construida sobre la plataforma y una construida contra ella. El navegador ya sabe enviar datos, mostrar errores de validación, gestionar el estado de foco y navegar al resultado. Lleva décadas sabiéndolo. Sin embargo el reflejo por defecto de casi todo el mundo, yo incluido durante años, es interceptar el submit, preventDefault(), montar un estado de React y reimplementar todo eso peor. Este artículo es sobre el patrón contrario: escribir el formulario para que funcione sin una línea de JavaScript, y luego usar JavaScript para hacerlo mejor —no para hacerlo posible.
El caso base: un <form> que ya funciona
Un formulario con action y method envía datos al servidor y navega al resultado. Sin JS. Los inputs con required, type="email", minlength o pattern validan en el cliente sin una línea de código. El <label> asociado da accesibilidad gratis. El botón de submit muestra un estado de carga nativo del navegador.
<form action="/api/contacto" method="post">
<label for="email">Correo</label>
<input id="email" name="email" type="email" required />
<button type="submit">Enviar</button>
</form>
Eso funciona en un móvil con la red a medias, mientras tu bundle todavía se descarga, con JS deshabilitado, o cuando un script de terceros revienta y se lleva por delante la hidratación. En un sitio Astro, donde la mayoría de las islas ni siquiera se hidratan, esto no es una hipótesis: es el comportamiento normal de la página.
La validación no puede vivir en un solo sitio
Aquí es donde suele romperse la disciplina. La gente valida en el cliente con una librería, y en el servidor vuelve a validar con otra lógica escrita a mano. Dos fuentes de verdad, que divergen en cuanto alguien cambia una regla.
Lo que hago es tener un único esquema —Zod— y usarlo en los dos lados. En el servidor es la verdad; el cliente es una cortesía para dar feedback rápido. Y crucialmente: si el JavaScript no cargó, el servidor sigue validando y devolviendo una página con los errores. El caso base nunca queda desprotegido.
// compartido
export const contacto = z.object({
email: z.string().email('Correo no válido'),
mensaje: z.string().min(10, 'Cuéntame un poco más'),
});
// servidor: la única verdad
export async function POST({ request }: { request: Request }) {
const datos = Object.fromEntries(await request.formData());
const res = contacto.safeParse(datos);
if (!res.success) {
// sin JS: renderiza la página de vuelta con los errores
return renderConErrores(datos, res.error.flatten().fieldErrors);
}
await guardar(res.data);
return new Response(null, { status: 303, headers: { Location: '/gracias' } });
}
Fíjate en el 303 con Location. Ese redirect después de un POST exitoso es el patrón POST/Redirect/GET, y evita que refrescar la página reenvíe el formulario. Es de 1998 y sigue siendo correcto.
💡 Si tu formulario se rompe cuando falla el JavaScript, no tenías un formulario. Tenías un widget que se parecía a uno.
Añadir JavaScript encima, no debajo
Con el caso base sólido, el JS entra a mejorar. Y lo hace escuchando el submit del formulario que ya existe, no construyendo uno nuevo.
form.addEventListener('submit', async (e) => {
e.preventDefault(); // solo se ejecuta si este script cargó
const datos = new FormData(form);
const res = contacto.safeParse(Object.fromEntries(datos));
if (!res.success) return pintarErrores(res.error.flatten().fieldErrors);
boton.disabled = true;
const r = await fetch(form.action, { method: 'POST', body: datos });
boton.disabled = false;
if (r.redirected) location.assign(r.url);
});
La clave está en el orden mental: el preventDefault() es una optimización opcional que solo ocurre si el script se ejecutó. Si no se ejecuta, el navegador hace su trabajo de siempre. No hay ruta rota, hay una ruta menos pulida.
Y fíjate en algo más: uso new FormData(form) en vez de leer un estado de React. El DOM ya es el estado del formulario. Mantener una copia en useState sincronizada con cada onChange es trabajo que la plataforma hace gratis, y es la causa de la mitad de los bugs de formularios que he depurado: inputs controlados que pierden el cursor, autocompletado del navegador que no dispara el evento, gestores de contraseñas que rellenan campos y el estado de React no se entera.
Lo que gano y lo que pago
Gano robustez real: el formulario funciona antes de la hidratación, con red mala, con JS caído. Gano accesibilidad casi gratis, porque los elementos nativos ya traen su semántica. Gano menos código: no hay reducer de estado de formulario, no hay librería de formularios, no hay validación duplicada.
Lo que pago es que ciertas interacciones muy ricas cuestan más. Un campo con autocompletado remoto, arrastrar y soltar archivos, o un asistente de varios pasos con estado complejo no salen de un <form> pelado. Ahí sí monto un componente controlado, pero de forma localizada: la isla compleja es controlada, y el resto del formulario sigue siendo la plataforma. Elegí esto porque la complejidad se queda contenida donde la necesito, a cambio de tener dos estilos de manejo de campos conviviendo en el mismo archivo, lo cual es feo si no lo documentas.
También pago que el caso "sin JS" hay que probarlo. Si no lo pruebas, se pudre. En los proyectos donde esto me importa, tengo un test que envía el formulario con una petición HTTP plana, sin navegador, y comprueba que la respuesta trae los errores renderizados. Es un test barato y es el único que garantiza que el caso base sigue vivo.
La conclusión no es "no uses JavaScript". Es que el JavaScript debería ser la segunda capa, no los cimientos. Un formulario que solo existe cuando el bundle carga es un formulario con una dependencia que nunca acordaste asumir.