Subidas de archivos seguras con Claude Code: FormData, validación, vista previa y S3
Guía SaaS para uploads seguros con Claude Code: File API, FormData, fetch, validación, progreso y S3.
La subida de archivos parece una función pequeña hasta que la pones en producción. Una imagen de perfil, una factura PDF, una importación CSV, un contrato o un adjunto de chat empiezan con el mismo gesto: elegir un archivo. Detrás de ese botón hay APIs del navegador, formato de envío, validación del servidor, permisos de almacenamiento, vista previa, progreso, costes y auditoría.
Claude Code puede acelerar mucho este trabajo. Puede crear el componente React, la ruta API, el validador, el adaptador de almacenamiento y el README. Pero necesita límites claros. Si el prompt solo dice “implementa subida de archivos”, puede generar una demo que funciona, pero guarda el nombre original, confía demasiado en el MIME del navegador, omite el límite de tamaño o devuelve una URL pública antes de tiempo.
En esta guía vamos por pasos. Primero separamos File API, FormData y Fetch API. Luego hacemos una subida mínima, añadimos vista previa y progreso real en React, validamos en el servidor y decidimos cuándo mover los archivos a S3, Cloud Storage, Azure Blob Storage o Cloudflare R2.
Para el diseño de S3, conviene leer también Claude Code y AWS S3. Para la revisión de permisos, usa la lista de buenas prácticas de seguridad con Claude Code.
Separar las responsabilidades del navegador
La primera pieza es File API. Cuando el usuario selecciona un archivo con<input type="file">o lo arrastra a la página, el navegador entrega un objetoFile. De ahí puedes leerfile.name, file.size, file.type y file.lastModified. Si es una imagen, puedes crear una URL temporal para vista previa conURL.createObjectURL(file).
La segunda pieza es FormData. Es el contenedor que permite enviar campos y archivos comomultipart/form-data. Normalmente se usaformData.append("file", file)y luego se pasa como body de la petición. Detalle importante: al usar FormData con fetch, no fijes manualmente el encabezadoContent-Type. El navegador debe añadir el boundary de multipart; si lo escribes a mano, puedes romper la petición.
La tercera pieza es fetch. Para una subida sencilla es suficiente:
const formData = new FormData();
formData.append("file", file);
await fetch("/api/upload", {
method: "POST",
body: formData
});
La excepción es el progreso real. Fetch es limpio para enviar la petición, pero los eventos de progreso de subida siguen siendo más directos conXMLHttpRequest.upload.onprogress. Por eso suelo pedir a Claude Code: fetch para el camino simple; XMLHttpRequest cuando el producto necesita una barra de progreso real.
Implementación mínima con HTML y fetch
Antes de React y S3, confirma el flujo básico: elegir archivo, validar en el navegador, mostrar vista previa y enviar FormData.
<form id="upload-form">
<input id="file-input" name="file" type="file" accept="image/png,image/jpeg,application/pdf" />
<button type="submit">Subir</button>
</form>
<img id="preview" alt="" style="max-width: 240px; display: none;" />
<p id="message"></p>
<script type="module">
const MAX_BYTES = 5 * 1024 * 1024;
const allowedTypes = new Set(["image/png", "image/jpeg", "application/pdf"]);
const form = document.querySelector("#upload-form");
const input = document.querySelector("#file-input");
const preview = document.querySelector("#preview");
const message = document.querySelector("#message");
input.addEventListener("change", () => {
const file = input.files?.[0];
preview.style.display = "none";
preview.removeAttribute("src");
message.textContent = "";
if (!file) return;
if (!allowedTypes.has(file.type)) {
message.textContent = "Solo se permiten PNG, JPEG y PDF.";
input.value = "";
return;
}
if (file.size > MAX_BYTES) {
message.textContent = "El archivo debe pesar 5MB o menos.";
input.value = "";
return;
}
if (file.type.startsWith("image/")) {
preview.src = URL.createObjectURL(file);
preview.style.display = "block";
preview.onload = () => URL.revokeObjectURL(preview.src);
}
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const file = input.files?.[0];
if (!file) {
message.textContent = "Selecciona un archivo primero.";
return;
}
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/upload", {
method: "POST",
body: formData
});
const result = await response.json();
message.textContent = response.ok ? `Guardado: ${result.name}` : result.error;
});
</script>
La validación del cliente mejora la experiencia, pero no es seguridad. Un cliente malicioso puede saltarse este JavaScript. El servidor debe volver a comprobar MIME, extensión, tamaño, nombre de almacenamiento y ubicación.
React con vista previa y progreso real
En React, separa los estados: archivo seleccionado, URL de vista previa, progreso, error y nombre guardado. Ese diseño facilita que Claude Code añada drag and drop, cancelación o reintento sin romperlo todo.
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react";
const MAX_BYTES = 5 * 1024 * 1024;
const ALLOWED_TYPES = new Set(["image/png", "image/jpeg", "application/pdf"]);
type UploadResult = { ok: true; name: string; size: number; type: string };
export function FileUploadBox() {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [uploadedName, setUploadedName] = useState<string | null>(null);
const canUpload = useMemo(() => selectedFile && !error, [selectedFile, error]);
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0] ?? null;
setUploadedName(null);
setProgress(0);
setError(null);
if (previewUrl) URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
if (!file) return setSelectedFile(null);
if (!ALLOWED_TYPES.has(file.type)) {
setSelectedFile(null);
return setError("Solo se permiten PNG, JPEG y PDF.");
}
if (file.size > MAX_BYTES) {
setSelectedFile(null);
return setError("El archivo debe pesar 5MB o menos.");
}
setSelectedFile(file);
if (file.type.startsWith("image/")) setPreviewUrl(URL.createObjectURL(file));
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedFile) return;
const formData = new FormData();
formData.append("file", selectedFile);
const result = await uploadWithProgress(formData, setProgress);
setUploadedName(result.name);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<input type="file" accept="image/png,image/jpeg,application/pdf" onChange={handleFileChange} />
{previewUrl && <img src={previewUrl} alt="Vista previa del archivo" width={240} />}
{selectedFile && <p>{selectedFile.name} / {Math.round(selectedFile.size / 1024)}KB</p>}
{error && <p role="alert">{error}</p>}
<progress value={progress} max={100}>{progress}%</progress>
<button type="submit" disabled={!canUpload}>Subir</button>
{uploadedName && <p>Guardado: {uploadedName}</p>}
</form>
);
}
function uploadWithProgress(formData: FormData, onProgress: (progress: number) => void) {
return new Promise<UploadResult>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/upload");
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
});
xhr.addEventListener("load", () => {
const body = JSON.parse(xhr.responseText || "{}");
if (xhr.status >= 200 && xhr.status < 300) resolve(body);
else reject(new Error(body.error ?? "Upload failed"));
});
xhr.addEventListener("error", () => reject(new Error("Network error")));
xhr.send(formData);
});
}
Si no puedes medir el progreso real, no lo finjas con un temporizador. Para archivos pequeños, un estado “subiendo” es suficiente y más honesto. Para archivos grandes, usa un transporte que exponga progreso.
Validación del servidor
Este Route Handler de Next.js guarda en.local-uploads para la primera versión. En producción, sustituye esa parte por S3, Cloud Storage, Azure Blob Storage o R2.
// app/api/upload/route.ts
import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs";
const MAX_BYTES = 5 * 1024 * 1024;
const ALLOWED_TYPES = new Map([
["image/png", ".png"],
["image/jpeg", ".jpg"],
["application/pdf", ".pdf"]
]);
export async function POST(request: NextRequest) {
const formData = await request.formData();
const value = formData.get("file");
if (!(value instanceof File)) {
return NextResponse.json({ error: "Falta el archivo." }, { status: 400 });
}
const expectedExt = ALLOWED_TYPES.get(value.type);
const originalExt = path.extname(value.name).toLowerCase();
if (!expectedExt) return NextResponse.json({ error: "Tipo MIME no permitido." }, { status: 400 });
if (value.size === 0 || value.size > MAX_BYTES) {
return NextResponse.json({ error: "El archivo debe tener entre 1 byte y 5MB." }, { status: 400 });
}
if (expectedExt === ".jpg" && ![".jpg", ".jpeg"].includes(originalExt)) {
return NextResponse.json({ error: "Extensión JPEG inválida." }, { status: 400 });
}
if (expectedExt !== ".jpg" && originalExt !== expectedExt) {
return NextResponse.json({ error: "El MIME y la extensión no coinciden." }, { status: 400 });
}
const bytes = Buffer.from(await value.arrayBuffer());
const storedName = `${randomUUID()}${expectedExt === ".jpg" ? ".jpg" : expectedExt}`;
const uploadDir = path.join(process.cwd(), ".local-uploads");
await mkdir(uploadDir, { recursive: true });
await writeFile(path.join(uploadDir, storedName), bytes, { flag: "wx" });
return NextResponse.json({ ok: true, name: storedName, size: value.size, type: value.type });
}
Revisa cinco cosas: MIME, extensión, tamaño, nombre de almacenamiento y ubicación. El nombre original no se usa como clave. No se guarda directamente enpublic/. Para documentos sensibles, añade firma mágica, decodificación real, antivirus, autenticación, cuotas y logs de auditoría.
Cuándo moverlo a S3 o Cloud Storage
El almacenamiento local sirve para aprender y prototipos. Si el archivo es un activo del cliente, si vas a escalar a varios servidores, si necesitas lifecycle rules, miniaturas asíncronas o descargas privadas, muévelo a object storage.
No siempre necesitas subida directa desde el navegador a S3 desde el día uno. Para archivos pequeños, el servidor puede recibir, validar y subir. Cuando el tamaño o el tráfico lo justifiquen, introduce presigned URLs.
Convierte la subida local existente de Next.js a almacenamiento S3.
Estado actual: /api/upload recibe FormData y ya valida MIME, extensión y tamaño.
Restricciones: no uses el nombre original como S3 key. Guarda como uploads/yyyy/mm/dd/{uuid}.ext.
Restricciones: permite solo image/png, image/jpeg y application/pdf. Máximo 5MB.
Restricciones: el bucket sigue private. La API devuelve un ID de archivo, no una URL pública.
Entregables: app/api/upload/route.ts, lib/storage/s3.ts, tests de error y README con variables de entorno.
Verificación: explica pruebas para archivo grande, extensión incorrecta, usuario no autenticado y subida correcta.
Casos reales y errores frecuentes
Caso uno: imagen de perfil. La vista previa, el recorte, el reintento y la generación de miniaturas importan. Empieza con PNG, JPEG y WebP; no permitas SVG sin revisar cómo se servirá.
Caso dos: importación CSV. La subida es solo la entrada. Lo importante es validar columnas, encoding, número de filas, duplicados, rollback y reporte de errores.
Caso tres: facturas y contratos PDF. No devuelvas una URL pública al terminar. Guarda en privado, verifica permisos y entrega una URL firmada de corta duración solo después de autorizar.
Errores comunes: trataracceptcomo seguridad, guardar el nombre original, devolver URL pública demasiado pronto, mostrar progreso falso y dar permisos S3 demasiado amplios.
Prompt para Claude Code y nota de Masa
Añade una subida de archivos segura a esta app Next.js.
Objetivo: pantalla admin SaaS que sube un PNG, JPEG o PDF por vez.
Cliente: componente React con File API; mostrar nombre, tamaño, vista previa, error y estado de subida.
Transporte: POST con FormData a /api/upload. Si se necesita progreso real, usa XMLHttpRequest y explica por qué no fetch.
Servidor: en app/api/upload/route.ts recibe FormData y valida MIME, extensión, máximo 5MB y archivo vacío.
Almacenamiento: no uses el nombre original como nombre guardado. Usa UUID + extensión en .local-uploads.
Prohibido: no guardar directo en public/. No afirmar que extensión es seguridad completa. No hacer público el bucket S3.
Verificación: explicar pruebas de exceso de tamaño, extensión incorrecta, archivo faltante y subida correcta.
Referencias: MDN File API, FormData y Fetch API.
En mis pruebas, la mejora más clara apareció al separar “subida simple con fetch” de “subida con progreso real”. Cuando pedía progreso sin aclarar, el resultado podía ser una barra bonita pero no conectada al progreso real. También fue mejor empezar con almacenamiento local y cambiar solo el adaptador después. Así se depuran File API, FormData, validación y vista previa antes de sumar IAM, CORS y URLs firmadas.
Resumen
Una subida segura no es solo un input. Es una frontera entre el dispositivo del usuario, tu servidor, el almacenamiento y el modelo de permisos. Claude Code puede implementarla rápido si el prompt deja claras las restricciones: File API en el cliente, FormData para transportar, validación del servidor, límites de tamaño, claves generadas, progreso honesto y almacenamiento privado cuando el producto crece.
Si quieres aplicar este patrón a un repositorio real, la formación y consultoría de Claude Code puede cubrir flujo de subida, S3, URLs firmadas, pruebas y checklist de revisión. También puedes empezar con el PDF gratuito y los materiales de aprendizaje.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.