Tu formulario ya valida y todo se pone en verde. Pulsas Enviar y pasa una de dos cosas: la página se recarga y pierdes lo que habías escrito, o no pasa nada y los datos no llegan a ningún sitio. Validar solo comprueba lo que hay en los campos; para que viajen hasta el servidor tienes que enviarlos tú.
Aquí conectas ese formulario, el de la guía de validación con JavaScript, con la API de tareas del tutorial de Spring Boot. Mandas los datos con fetch, lees la respuesta y, si el servidor rechaza algo, pintas el mensaje en el campo que toca. Los fragmentos de código son ejemplos sueltos para entender cada pieza, no un proyecto para copiar entero.
Qué pasa cuando envías un formulario
Sin JavaScript, el navegador hace todo el trabajo: coge los campos, los manda a la dirección del action y carga como página nueva lo que responda el servidor. Así funciona el formulario de contacto con PHP y MySQL, y por eso la página se recarga entera.
Con fetch la petición la lanzas tú desde JavaScript y la página se queda donde está. Es como mandar un WhatsApp en vez de ir a la ventanilla: mandas el mensaje, sigues con lo que estabas haciendo y la respuesta te llega cuando llega. Tú decides qué hacer con ella.
Una petición HTTP tiene cuatro piezas que vas a tocar: el método (POST para crear algo), la URL, las cabeceras (datos sobre lo que envías) y el cuerpo, que son los datos en sí. En crudo, la que vas a construir se ve así:
POST /api/tasks HTTP/1.1
Host: localhost:8080
Content-Type: application/json
{"title": "Comprar el pan", "description": "", "completed": false}
La cabecera Content-Type le dice al servidor en qué formato viene el cuerpo. Esa línea es la que más problemas da, y la vas a ver enseguida.
El endpoint que recibe los datos
En el tutorial de Spring Boot creaste POST /api/tasks. Recibe un TaskRequest y la anotación @Valid comprueba sus reglas antes de ejecutar el método:
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Task create(@Valid @RequestBody TaskRequest request) {
return taskService.create(request);
}
public record TaskRequest(
@NotBlank(message = "El título es obligatorio")
@Size(min = 3, max = 100, message = "El título debe tener entre 3 y 100 caracteres")
String title,
String description,
Boolean completed
) {}
@RequestBody le dice a Spring que convierta el JSON del cuerpo en ese objeto. Si cumple las reglas, el servidor guarda la tarea y responde 201 Created con la tarea y su id. Si no las cumple, el método ni se ejecuta y el GlobalExceptionHandler del tutorial devuelve un 400. Eso lo recogerás en el formulario más abajo.
Los ejemplos usan ProblemDetail, que existe desde Spring Boot 3. La versión estable actual es la 4.1, de junio de 2026.
Enviar JSON con fetch
Si nunca has usado fetch, empieza por la primera llamada con fetch(), que cubre la lectura de datos con GET. Para enviar, el objeto de opciones lleva tres cosas: el método, las cabeceras y el cuerpo.
const API_URL = 'http://localhost:8080/api/tasks';
async function crearTarea() {
const respuesta = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Comprar el pan', description: '' })
});
return respuesta;
}
JSON.stringify convierte el objeto de JavaScript en texto JSON, porque el cuerpo de una petición viaja siempre como texto. La llamada es asíncrona, así que va con await dentro de una función async. Si eso te suena raro, repásalo en el post de async/await.
❌ Olvidar el Content-Type:
Sin esa cabecera, fetch manda el texto como text/plain y Spring responde 415 Unsupported Media Type sin ejecutar nada. Aquí es donde la mayoría se atasca: el JSON está perfecto y aun así el servidor lo rechaza. Si ves un 415, mira primero las cabeceras de la petición.
Enviar los campos del formulario con FormData
Escribir a mano cada campo en un objeto no escala cuando el formulario crece. FormData recorre el formulario y recoge los valores por ti. Para que funcione, cada campo necesita su atributo name:
<form id="tarea-form">
<label for="title">Título</label>
<input type="text" id="title" name="title" aria-describedby="title-error">
<span id="title-error" role="alert"></span>
<label for="description">Descripción</label>
<input type="text" id="description" name="description" aria-describedby="description-error">
<span id="description-error" role="alert"></span>
<button type="submit">Guardar tarea</button>
</form>
FormData identifica cada campo por su name, no por el id. Un input sin name simplemente no aparece en los datos, sin ningún aviso. Esto me costó pillarlo la primera vez: todo parecía bien y el servidor recibía el objeto a medias.
const formulario = document.getElementById('tarea-form');
formulario.addEventListener('submit', async (evento) => {
evento.preventDefault();
const datos = Object.fromEntries(new FormData(formulario));
// datos = { title: 'Comprar el pan', description: '' }
});
Object.fromEntries convierte los datos de FormData en un objeto normal, que sí puedes pasar por JSON.stringify. Si varios campos comparten name, como los checkboxes, se queda solo con el último.
❌ Mandar el FormData tal cual a un endpoint JSON:
Si pasas el FormData directo como body, el navegador lo envía como multipart/form-data y tu @RequestBody responde otro 415, porque solo sabe leer JSON. Tampoco escribas el Content-Type a mano en ese caso: el navegador añade un identificador interno (el boundary) y, si lo sobrescribes, la petición se rompe. FormData directo se usa cuando el endpoint espera archivos, como en subir archivos con validación segura.
Leer la respuesta y gestionar los errores
Aquí está la sorpresa más grande de fetch: solo falla, es decir, solo salta al catch, cuando no consigue hablar con el servidor. Si está apagado o no hay red, por ejemplo. Un 400 o un 500 son, para fetch, una respuesta como otra cualquiera. Tienes que mirar respuesta.ok, que vale true con los códigos del 200 al 299.
const boton = formulario.querySelector('button[type="submit"]');
boton.disabled = true;
try {
const respuesta = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(datos)
});
const cuerpo = await respuesta.json();
if (!respuesta.ok) {
mostrarErroresDelServidor(cuerpo);
return;
}
// 201 Created: cuerpo es la tarea guardada, con su id
formulario.reset();
} catch (error) {
// sin conexión, servidor apagado o bloqueo de CORS
} finally {
boton.disabled = false;
}
El botón se desactiva mientras dura la petición y se reactiva en el finally, que se ejecuta siempre, incluso cuando el return sale antes.
❌ Dar por buena cualquier respuesta:
Si no compruebas respuesta.ok, tu formulario enseñará "Tarea guardada" justo cuando el servidor acaba de rechazar los datos. El segundo clásico es el doble envío: sin desactivar el botón, un usuario impaciente crea la misma tarea tres veces.
Pintar los errores del servidor en cada campo
Cuando falla la validación, el GlobalExceptionHandler del tutorial devuelve un 400 con este cuerpo. Spring pone las propiedades extra, como errors, en el nivel principal del JSON:
{
"detail": "Los datos enviados no son válidos",
"instance": "/api/tasks",
"status": 400,
"title": "Bad Request",
"errors": {
"title": "El título debe tener entre 3 y 100 caracteres"
}
}
Cada clave de errors es el nombre de un campo del TaskRequest, el mismo que le pusiste al name del input. Esa coincidencia te deja recorrer el objeto y escribir cada mensaje debajo de su campo:
function mostrarErroresDelServidor(problema) {
const errores = problema.errors ?? {};
for (const [campo, mensaje] of Object.entries(errores)) {
document.getElementById(`${campo}-error`).textContent = mensaje;
}
}
Fíjate en textContent y no en innerHTML: el mensaje viene de fuera y no tienes por qué fiarte de lo que contenga. El patrón accesible para esos mensajes (aria-invalid, role="alert" y el foco en el primer campo con error) está explicado en la guía de validación y funciona igual aquí.
Dos detalles que se escapan. El ?? {} evita que el código reviente cuando la respuesta no trae errors, algo que pasa con un 404 de tarea no encontrada o con un JSON mal formado. Y limpia los mensajes del intento anterior antes de cada envío, o se quedarán pegados aunque el usuario ya lo haya corregido.
CORS: el error rojo de la consola
Tarde o temprano ves esto en la consola: has been blocked by CORS policy. Pasa cuando el formulario y la API están en orígenes distintos. Un origen es la combinación de protocolo, dominio y puerto: tu página en http://127.0.0.1:5500 (Live Server) y tu API en http://localhost:8080 son dos orígenes, aunque estén en el mismo ordenador.
Por seguridad, el navegador solo deja que una web lea respuestas de otro origen si ese servidor lo autoriza. Con JSON, antes del POST manda una petición de comprobación (OPTIONS, el preflight); si el servidor no la autoriza, el POST ni sale. Hay dos formas de resolverlo.
La más simple para aprender: sirve el HTML desde la propia aplicación de Spring, en src/main/resources/static/. Página y API comparten origen, no hay CORS y puedes usar una ruta relativa, fetch('/api/tasks').
La que usarás en proyectos reales: autorizar en Spring el origen de tu frontend.
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://127.0.0.1:5500")
.allowedMethods("GET", "POST", "PUT", "DELETE");
}
}
❌ localhost no es 127.0.0.1:
Para el navegador son orígenes distintos. Si autorizas http://localhost:5500 y abres la página con http://127.0.0.1:5500, el error sigue ahí. Y como se dice en el tutorial de Spring Boot: en producción, nunca *; pon el dominio concreto de tu web.
Errores que te vas a encontrar
415 Unsupported Media Type: faltaContent-Type: application/jsono estás enviando unFormDataa un endpoint que espera JSON.400sin campoerrors: el JSON está mal formado (una coma de más, por ejemplo) o el cuerpo no es lo que espera el endpoint.TypeError: Failed to fetch: servidor apagado, URL mal escrita o CORS. Abre la pestaña Network de las DevTools y mira qué ha salido de verdad; el post de Chrome DevTools te enseña a leerla.- Funciona en tu máquina y falla al subirlo: dejaste
http://localhost:8080escrito en el código. Guarda la URL base en una variable por entorno, como se explica en variables de entorno.
La validación del navegador no es seguridad
Todo esto mejora la experiencia, pero cualquiera puede saltarse tu formulario y mandar una petición directa a la API. Por eso el @Valid del servidor es el que de verdad cuenta; las validaciones de JavaScript de la guía de validación solo ahorran viajes de ida y vuelta. Y nada de claves ni tokens secretos en el JavaScript del navegador: todo lo que llega ahí es público.
Preguntas frecuentes
¿fetch o Axios?
Empieza con fetch: viene en el navegador y no instalas nada. Axios es una librería que ahorra algo de código (convierte el objeto a JSON por ti y salta al catch con un 400 o un 500), pero para un formulario normal fetch sobra. Con lo que has visto, la diferencia son dos líneas.
¿POST o PUT?
POST crea un recurso nuevo. PUT reemplaza uno que ya existe y lleva su id en la URL. En el tutorial de Spring Boot, POST /api/tasks crea y PUT /api/tasks/{id} modifica.
¿Puedo usar el submit normal, sin fetch, con este endpoint?
Puedes, pero un formulario HTML clásico envía los datos como application/x-www-form-urlencoded, no como JSON, y un @RequestBody no sabe leerlos. Tendrías que cambiar el endpoint para recibir los campos de otra forma (con @ModelAttribute o @RequestParam), y la página se recargaría.
Siguiente paso: editar una tarea
Coge el PUT /api/tasks/{id} del tutorial de Spring Boot y monta el formulario de edición: carga la tarea con un GET para rellenar los campos y envía los cambios con la misma estructura que has visto aquí. Cambia el método a PUT y añade el id a la URL; las cabeceras, la comprobación de respuesta.ok y los errores por campo son idénticos.