Home / Frontend / Subir, recortar y redimensionar imágenes en Next.js paso a paso
Subir, recortar y redimensionar imágenes en Next.js paso a paso

Subir, recortar y redimensionar imágenes en Next.js paso a paso

20 October 2026 · 19 min de lectura ··

Por qué las imágenes merecen un tratamiento especial

Casi todas las aplicaciones web acaban permitiendo que sus usuarios suban imágenes: fotos de perfil, portadas de artículos, productos de una tienda, documentos escaneados o capturas para un sistema de soporte. Y casi todas descubren pronto el mismo problema: las imágenes que llegan son enormes, tienen proporciones arbitrarias y pesan varios megabytes. Mostrar esas fotos tal cual ralentiza la página, consume datos móviles y rompe el diseño.

La solución pasa por dos operaciones básicas. Redimensionar consiste en cambiar las dimensiones de la imagen para adaptarla al espacio donde se va a mostrar. Recortar consiste en quedarse solo con una parte, normalmente para ajustarla a una proporción concreta, como el cuadrado de un avatar o el formato panorámico de una portada.

En este artículo vamos a implementar ambas operaciones en una aplicación de Next.js con dos enfoques complementarios. El primero delega el almacenamiento y las transformaciones en Cloudinary, un servicio especializado en gestión de medios, usando su SDK oficial para Next.js. El segundo procesa las imágenes directamente en el navegador con la API de Canvas, sin depender de ningún servicio externo. Veremos cuándo conviene cada uno, cómo combinarlos y qué precauciones de seguridad y rendimiento hay que tener en cuenta.

Los ejemplos usan Next.js 16, cuya versión más reciente es la 16.4, con el App Router, React 19 y la versión 6 del paquete next-cloudinary. Conviene advertir desde el principio que redimensionar o recomprimir una imagen siempre implica cierta pérdida de calidad, por lo que es recomendable conservar el archivo original cuando sea posible.

Crear el proyecto

Empezamos generando una aplicación nueva con la herramienta oficial. El asistente interactivo propone por defecto TypeScript, ESLint, Tailwind CSS y el App Router, una configuración razonable para este proyecto:

npx create-next-app@latest editor-imagenes
cd editor-imagenes
npm run dev
   ▲ Next.js 16.4.0 (Turbopack)
   - Local:        http://localhost:3000

 ✓ Ready in 1.2s

La estructura de archivos que construiremos a lo largo del artículo será la siguiente:

editor-imagenes/
├── .env.local
├── next.config.ts
├── app/
│   ├── layout.tsx
│   ├── page.tsx
│   └── api/
│       └── firma-cloudinary/
│           └── route.ts
├── components/
│   ├── SubidaCloudinary.tsx
│   ├── VistaTransformada.tsx
│   └── RecortadorLocal.tsx
└── lib/
    └── procesar-imagen.ts

Enfoque 1: Cloudinary como servicio de imágenes

Cloudinary almacena las imágenes en la nube y permite transformarlas sobre la marcha mediante parámetros en la URL. Una misma imagen original puede servirse recortada a 400 por 400 píxeles para un avatar, a 1200 de ancho para una portada y en formato AVIF o WebP según el navegador, sin generar ni guardar manualmente cada variante. Las versiones transformadas se generan la primera vez que se solicitan y después se sirven desde su CDN.

Para empezar, crea una cuenta gratuita y anota el nombre de tu cloud, que aparece en el panel principal. Después instala el SDK oficial para Next.js:

npm install next-cloudinary

El paquete necesita una única variable de entorno obligatoria con el nombre de tu cloud:

# .env.local
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME="mi-cloud"

El prefijo NEXT_PUBLIC_ hace que la variable esté disponible en el navegador, lo cual es seguro en este caso porque el nombre del cloud no es un secreto. Más adelante añadiremos las credenciales para las subidas firmadas, que nunca deben llevar ese prefijo.

Una nota de actualización: muchos tutoriales antiguos usan el paquete cloudinary-react y cargan manualmente el script del widget de subida en la cabecera de la página. Ese SDK aparece hoy como heredado en la documentación de Cloudinary, y para Next.js lo recomendable es next-cloudinary, que integra el widget y la optimización de imágenes con los componentes del framework.

El widget de subida

El componente CldUploadWidget encapsula el widget de subida de Cloudinary: un cuadro de diálogo que permite elegir archivos del equipo, arrastrarlos, tomarlos de la cámara o de servicios externos, e incluso recortarlos antes de subirlos.

Como el widget depende del navegador, el componente que lo usa debe ser un componente de cliente, marcado con la directiva 'use client':

// components/SubidaCloudinary.tsx
"use client";

import { CldUploadWidget } from "next-cloudinary";

type Props = {
    onSubida: (publicId: string) => void;
};

export default function SubidaCloudinary({ onSubida }: Props) {
    return (
        <CldUploadWidget
            uploadPreset="editor_sin_firma"
            options={{
                sources: ["local", "camera", "url"],
                multiple: false,
                maxFileSize: 5_000_000,
                clientAllowedFormats: ["jpg", "jpeg", "png", "webp", "avif"],
                cropping: true,
                croppingAspectRatio: 1,
            }}
            onSuccess={(resultado) => {
                if (
                    typeof resultado.info === "object" &&
                    resultado.info?.public_id
                ) {
                    onSubida(resultado.info.public_id);
                }
            }}
        >
            {({ open }) => (
                <button type="button" onClick={() => open()}>
                    Subir imagen
                </button>
            )}
        </CldUploadWidget>
    );
}

La propiedad uploadPreset indica qué configuración de subida aplicar. Los presets se crean en el panel de Cloudinary, en la sección de configuración de subidas, y definen cosas como la carpeta de destino, las transformaciones automáticas o si la subida debe estar firmada. Para empezar a probar, puedes crear un preset sin firma; más adelante veremos por qué en producción conviene firmar las subidas.

Las opciones del widget permiten limitar las fuentes, el número de archivos, el tamaño máximo y los formatos aceptados, y activar un recortador integrado con una proporción fija. Cuando la subida termina, el callback onSuccess recibe la información del archivo, de la que nos interesa sobre todo el public_id, el identificador con el que referenciaremos la imagen.

Mostrar la imagen recortada y redimensionada

Una vez subida, la imagen se muestra con CldImage, un componente que extiende el componente Image de Next.js y traduce sus propiedades en transformaciones de Cloudinary. Las transformaciones de imagen bajo demanda se aplican en la URL, de modo que cambiar el recorte o el tamaño no requiere volver a subir nada:

// components/VistaTransformada.tsx
"use client";

import { CldImage } from "next-cloudinary";

type Modo = "fill" | "fit" | "scale" | "thumb";

type Props = {
    publicId: string;
    ancho: number;
    alto: number;
    modo: Modo;
};

export default function VistaTransformada({
    publicId,
    ancho,
    alto,
    modo,
}: Props) {
    return (
        <CldImage
            src={publicId}
            width={ancho}
            height={alto}
            crop={modo}
            gravity="auto"
            format="auto"
            quality="auto"
            alt="Vista previa de la imagen transformada"
            sizes="(max-width: 768px) 100vw, 50vw"
        />
    );
}

Los modos de recorte más utilizados se comportan de forma muy distinta:

  • scale cambia las dimensiones exactamente a las indicadas, aunque eso deforme la imagen si la proporción no coincide.
  • fit encaja la imagen completa dentro del rectángulo indicado sin deformarla, por lo que una de sus dimensiones puede quedar más pequeña.
  • fill rellena exactamente el rectángulo recortando lo que sobra, sin deformar.
  • thumb genera miniaturas recortadas, especialmente útil combinada con la detección de caras.

La propiedad gravity="auto" merece especial atención. Cuando hay que recortar, Cloudinary analiza el contenido de la imagen y elige automáticamente la zona más relevante, como un rostro o el objeto principal, en lugar de recortar siempre desde el centro. Con format="auto" y quality="auto", el servicio elige el formato más eficiente que admita el navegador y ajusta la compresión al contenido.

Una interfaz para elegir dimensiones y modo de recorte

Para que el usuario pueda experimentar, construimos la página principal con un formulario que controla el ancho, el alto y el modo:

// app/page.tsx
"use client";

import { useState } from "react";
import SubidaCloudinary from "@/components/SubidaCloudinary";
import VistaTransformada from "@/components/VistaTransformada";

const MODOS = [
    { valor: "fill", etiqueta: "Rellenar y recortar" },
    { valor: "fit", etiqueta: "Encajar sin recortar" },
    { valor: "scale", etiqueta: "Escalar exacto" },
    { valor: "thumb", etiqueta: "Miniatura" },
] as const;

export default function Pagina() {
    const [publicId, setPublicId] = useState<string | null>(null);
    const [ancho, setAncho] = useState(600);
    const [alto, setAlto] = useState(400);
    const [modo, setModo] = useState<(typeof MODOS)[number]["valor"]>("fill");

    return (
        <main className="mx-auto max-w-3xl p-6 space-y-6">
            <h1 className="text-2xl font-semibold">Editor de imágenes</h1>

            <SubidaCloudinary onSubida={setPublicId} />

            <fieldset className="flex flex-wrap gap-4">
                <legend className="font-medium">Modo</legend>
                {MODOS.map((m) => (
                    <label key={m.valor} className="flex items-center gap-2">
                        <input
                            type="radio"
                            name="modo"
                            value={m.valor}
                            checked={modo === m.valor}
                            onChange={() => setModo(m.valor)}
                        />
                        {m.etiqueta}
                    </label>
                ))}
            </fieldset>

            <div className="flex gap-4">
                <label>
                    Ancho
                    <input
                        type="number"
                        min={50}
                        max={2000}
                        value={ancho}
                        onChange={(e) => setAncho(Number(e.target.value))}
                    />
                </label>
                <label>
                    Alto
                    <input
                        type="number"
                        min={50}
                        max={2000}
                        value={alto}
                        onChange={(e) => setAlto(Number(e.target.value))}
                    />
                </label>
            </div>

            {publicId ? (
                <VistaTransformada
                    publicId={publicId}
                    ancho={ancho}
                    alto={alto}
                    modo={modo}
                />
            ) : (
                <p>Sube una imagen para ver la vista previa.</p>
            )}
        </main>
    );
}

Cada cambio en los controles genera una nueva URL de transformación, y Cloudinary sirve la variante correspondiente. Si inspeccionas el elemento img resultante, verás URLs con un aspecto similar a este:

https://res.cloudinary.com/mi-cloud/image/upload/c_fill,w_600,h_400,g_auto/f_auto/q_auto/v1/editor/paisaje-azul

Cada segmento corresponde a una transformación: c_fill es el modo de recorte, w_600 y h_400 las dimensiones, g_auto la gravedad automática, y f_auto y q_auto el formato y la calidad automáticos.

Subidas firmadas: protege tu cuenta

Un preset sin firma es cómodo para empezar, pero tiene un problema serio en producción: cualquiera que inspeccione tu página puede ver el nombre del preset y usarlo para subir archivos a tu cuenta desde fuera de tu aplicación, consumiendo tu almacenamiento y tu cuota.

Las subidas firmadas con Cloudinary resuelven este problema. En lugar de permitir subidas anónimas, tu servidor firma cada solicitud con el secreto de la API, que nunca sale del servidor. El widget pide la firma a un endpoint propio antes de subir el archivo.

Primero añade las credenciales a las variables de entorno. Fíjate en que el secreto no lleva el prefijo NEXT_PUBLIC_:

# .env.local
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME="mi-cloud"
NEXT_PUBLIC_CLOUDINARY_API_KEY="123456789012345"
CLOUDINARY_API_SECRET="secreto-que-nunca-debe-llegar-al-cliente"

Después instala el SDK de Node.js de Cloudinary y crea un Route Handler que genere la firma:

npm install cloudinary
// app/api/firma-cloudinary/route.ts
import { v2 as cloudinary } from "cloudinary";

export async function POST(request: Request) {
    // Aquí deberías comprobar que el usuario ha iniciado sesión
    const { paramsToSign } = await request.json();

    const signature = cloudinary.utils.api_sign_request(
        paramsToSign,
        process.env.CLOUDINARY_API_SECRET!
    );

    return Response.json({ signature });
}

Por último, en el widget, sustituye el preset sin firma por uno firmado e indica el endpoint:

<CldUploadWidget
    uploadPreset="editor_firmado"
    signatureEndpoint="/api/firma-cloudinary"
    onSuccess={(resultado) => {
        /* ... */
    }}
>
    {({ open }) => <button onClick={() => open()}>Subir imagen</button>}
</CldUploadWidget>

El endpoint de firma debe protegerse con la autenticación de tu aplicación. Si cualquiera puede pedir firmas, el problema simplemente se traslada de lugar. Del mismo modo, conviene configurar en el preset firmado restricciones como los formatos permitidos, el tamaño máximo y la carpeta de destino, de forma que no dependan solo de las opciones del cliente, que siempre pueden manipularse.

Enfoque 2: recortar y redimensionar en el navegador

No todos los proyectos quieren o pueden depender de un servicio externo. Quizá las imágenes deben almacenarse en tu propio almacenamiento de objetos, quizá hay requisitos de privacidad o simplemente quieres reducir costes. En esos casos, puedes recortar imágenes en el navegador antes de subirlas, usando la API de Canvas.

Procesar en el cliente tiene ventajas claras: la imagen llega al servidor ya con el tamaño final, lo que reduce el tiempo de subida, el ancho de banda y el trabajo del servidor. Es especialmente valioso en móviles, donde una foto recién tomada puede pesar varios megabytes.

El núcleo del proceso es una función que recibe un archivo, el área a recortar y las dimensiones finales, y devuelve un nuevo Blob comprimido:

// lib/procesar-imagen.ts
export type Area = { x: number; y: number; ancho: number; alto: number };

export async function recortarYRedimensionar(
    archivo: File,
    area: Area,
    anchoFinal: number,
    altoFinal: number,
    tipo: "image/webp" | "image/jpeg" = "image/webp",
    calidad = 0.85
): Promise<Blob> {
    // Decodifica respetando la orientación EXIF de las fotos de móviles
    const bitmap = await createImageBitmap(archivo, {
        imageOrientation: "from-image",
    });

    const canvas = document.createElement("canvas");
    canvas.width = anchoFinal;
    canvas.height = altoFinal;

    const ctx = canvas.getContext("2d");
    if (!ctx) throw new Error("No se pudo obtener el contexto 2D");

    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = "high";

    // Copia el área recortada del original escalándola al tamaño final
    ctx.drawImage(
        bitmap,
        area.x,
        area.y,
        area.ancho,
        area.alto,
        0,
        0,
        anchoFinal,
        altoFinal
    );
    bitmap.close();

    return new Promise((resolve, reject) => {
        canvas.toBlob(
            (blob) =>
                blob
                    ? resolve(blob)
                    : reject(new Error("Error al generar la imagen")),
            tipo,
            calidad
        );
    });
}

La clave está en la versión de nueve argumentos de drawImage. Los cuatro primeros números tras la imagen definen el rectángulo de origen que se va a recortar; los cuatro siguientes, el rectángulo de destino en el lienzo. Al hacer que el destino ocupe todo el canvas con las dimensiones finales, recortamos y redimensionamos en una sola operación.

Usar createImageBitmap con la opción imageOrientation: 'from-image' resuelve un problema clásico: muchas fotos de móviles se guardan giradas y llevan la orientación correcta en sus metadatos EXIF. Si no se tiene en cuenta, la imagen procesada puede aparecer tumbada.

Para recortes a una proporción fija sin interfaz de selección, como un avatar cuadrado, basta con calcular el área central:

export function areaCentral(
    anchoOriginal: number,
    altoOriginal: number,
    proporcion: number
): Area {
    const proporcionOriginal = anchoOriginal / altoOriginal;
    if (proporcionOriginal > proporcion) {
        const ancho = altoOriginal * proporcion;
        return {
            x: (anchoOriginal - ancho) / 2,
            y: 0,
            ancho,
            alto: altoOriginal,
        };
    }
    const alto = anchoOriginal / proporcion;
    return { x: 0, y: (altoOriginal - alto) / 2, ancho: anchoOriginal, alto };
}

Un recortador interactivo en el cliente

Para que el usuario elija qué parte de la imagen conservar, lo más práctico es apoyarse en un componente de recorte existente. Librerías como react-easy-crop o react-image-crop ofrecen arrastre, zoom y proporciones fijas, y devuelven las coordenadas del área seleccionada en píxeles. Con react-easy-crop, la integración es directa:

npm install react-easy-crop
// components/RecortadorLocal.tsx
"use client";

import { useEffect, useState } from "react";
import Cropper, { type Area as AreaPixeles } from "react-easy-crop";
import { recortarYRedimensionar } from "@/lib/procesar-imagen";

const TIPOS_PERMITIDOS = [
    "image/jpeg",
    "image/png",
    "image/webp",
    "image/avif",
];
const TAMANO_MAXIMO = 10 * 1024 * 1024; // 10 MB

export default function RecortadorLocal() {
    const [archivo, setArchivo] = useState<File | null>(null);
    const [urlOrigen, setUrlOrigen] = useState<string | null>(null);
    const [recorte, setRecorte] = useState({ x: 0, y: 0 });
    const [zoom, setZoom] = useState(1);
    const [area, setArea] = useState<AreaPixeles | null>(null);
    const [resultado, setResultado] = useState<string | null>(null);
    const [error, setError] = useState<string | null>(null);

    // Libera las URL temporales al cambiar de imagen o desmontar
    useEffect(
        () => () => {
            if (urlOrigen) URL.revokeObjectURL(urlOrigen);
        },
        [urlOrigen]
    );
    useEffect(
        () => () => {
            if (resultado) URL.revokeObjectURL(resultado);
        },
        [resultado]
    );

    function elegirArchivo(e: React.ChangeEvent<HTMLInputElement>) {
        const elegido = e.target.files?.[0];
        if (!elegido) return;
        if (!TIPOS_PERMITIDOS.includes(elegido.type)) {
            setError("Formato no admitido. Usa JPEG, PNG, WebP o AVIF.");
            return;
        }
        if (elegido.size > TAMANO_MAXIMO) {
            setError("La imagen supera el tamaño máximo de 10 MB.");
            return;
        }
        setError(null);
        setArchivo(elegido);
        setUrlOrigen(URL.createObjectURL(elegido));
    }

    async function aplicarRecorte() {
        if (!archivo || !area) return;
        const blob = await recortarYRedimensionar(
            archivo,
            { x: area.x, y: area.y, ancho: area.width, alto: area.height },
            512,
            512
        );
        setResultado(URL.createObjectURL(blob));
    }

    return (
        <section className="space-y-4">
            <input
                type="file"
                accept={TIPOS_PERMITIDOS.join(",")}
                onChange={elegirArchivo}
            />
            {error && <p role="alert">{error}</p>}

            {urlOrigen && (
                <div className="relative h-80 w-full bg-neutral-900">
                    <Cropper
                        image={urlOrigen}
                        crop={recorte}
                        zoom={zoom}
                        aspect={1}
                        onCropChange={setRecorte}
                        onZoomChange={setZoom}
                        onCropComplete={(_, pixeles) => setArea(pixeles)}
                    />
                </div>
            )}

            {urlOrigen && (
                <button type="button" onClick={aplicarRecorte}>
                    Recortar a 512 × 512
                </button>
            )}

            {resultado && (
                <img
                    src={resultado}
                    alt="Resultado del recorte"
                    width={256}
                    height={256}
                />
            )}
        </section>
    );
}

El componente valida el tipo y el tamaño del archivo antes de cargarlo, muestra la imagen con URL.createObjectURL, que crea una URL temporal sin necesidad de convertirla a base64, y libera esas URL con URL.revokeObjectURL cuando ya no se necesitan, evitando fugas de memoria.

Enviar el resultado al servidor

Una vez procesada la imagen, hay que subirla. En Next.js, una opción cómoda es una Server Action que reciba el archivo dentro de un FormData:

// app/acciones.ts
"use server";

import { writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import path from "node:path";

const TIPOS = new Set(["image/webp", "image/jpeg", "image/png"]);
const MAXIMO = 2 * 1024 * 1024;

export async function guardarAvatar(datos: FormData) {
    const imagen = datos.get("imagen");
    if (!(imagen instanceof File))
        throw new Error("No se recibió ninguna imagen");
    if (!TIPOS.has(imagen.type))
        throw new Error("Tipo de archivo no permitido");
    if (imagen.size > MAXIMO) throw new Error("La imagen es demasiado grande");

    const nombre = `${randomUUID()}.webp`;
    const bytes = Buffer.from(await imagen.arrayBuffer());
    await writeFile(path.join(process.cwd(), "uploads", nombre), bytes);

    return { nombre };
}
// En el componente de cliente
const datos = new FormData();
datos.append("imagen", blob, "avatar.webp");
const { nombre } = await guardarAvatar(datos);

Por defecto, las Server Actions limitan el tamaño del cuerpo de la petición a 1 MB. Si necesitas aceptar imágenes mayores, el límite se configura en next.config.ts. La ubicación exacta de esta opción ha cambiado entre versiones del framework, así que conviene confirmarla en la documentación de la versión que uses:

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
    experimental: {
        serverActions: {
            bodySizeLimit: "3mb",
        },
    },
};

export default nextConfig;

Guardar en el disco local solo sirve para pruebas. En producción, y especialmente en plataformas sin servidor, lo habitual es enviar el archivo a un almacenamiento de objetos como Amazon S3, Google Cloud Storage o un servicio compatible. Un patrón muy eficiente es que el servidor genere una URL prefirmada y el navegador suba el archivo directamente al almacenamiento, sin que los bytes pasen por tu aplicación.

Cloudinary o procesamiento local: cómo elegir

Ambos enfoques son válidos, y la elección depende de las necesidades del proyecto.

Cloudinary encaja mejor cuando necesitas muchas variantes de cada imagen, como miniaturas, versiones para retina o formatos modernos según el navegador; cuando quieres recorte inteligente que detecte caras u objetos; cuando prefieres no gestionar almacenamiento ni CDN; o cuando las imágenes también se usan en otros canales, como correos o aplicaciones móviles. A cambio, añade una dependencia externa y un coste que crece con el uso.

El procesamiento en el navegador encaja mejor cuando solo necesitas una o dos variantes fijas, como un avatar de 512 píxeles; cuando quieres reducir el peso antes de la subida para ahorrar ancho de banda; cuando hay requisitos de privacidad que desaconsejan enviar originales a terceros; o cuando ya tienes tu propio almacenamiento. A cambio, el resultado depende de la potencia del dispositivo y de los formatos que admita su navegador.

También es posible combinarlos: recortar y reducir la imagen en el cliente para subir un archivo ligero, y después usar un servicio de imágenes o el propio componente Image de Next.js para servir variantes optimizadas. Si usas next/image con imágenes alojadas en otro dominio, recuerda declarar ese dominio en la configuración:

// next.config.ts
const nextConfig: NextConfig = {
    images: {
        remotePatterns: [
            {
                protocol: "https",
                hostname: "mis-imagenes.s3.eu-west-1.amazonaws.com",
            },
        ],
    },
};

Seguridad al aceptar imágenes de usuarios

Aceptar archivos de usuarios es una de las superficies de ataque más clásicas de cualquier aplicación web, y las imágenes no son una excepción.

Nunca confíes en la validación del cliente. El atributo accept del input, las comprobaciones de tipo en JavaScript y las opciones del widget mejoran la experiencia, pero cualquiera puede saltárselas. Repite siempre las validaciones en el servidor o en la configuración del preset firmado.

No te fíes de la extensión ni del tipo MIME declarado. Ambos pueden falsificarse. Para una validación robusta, comprueba la firma real del archivo, sus primeros bytes, o mejor aún, vuelve a codificar la imagen en el servidor con una librería como sharp. Volver a codificar elimina además los metadatos y cualquier contenido inesperado incrustado.

Elimina los metadatos EXIF de las fotos antes de publicarlas. Pueden contener la ubicación GPS exacta donde se tomó la foto, el modelo del dispositivo y otros datos personales. Al dibujar la imagen en un canvas y exportarla, los metadatos originales desaparecen automáticamente, una ventaja adicional del procesamiento en el navegador.

Ten cuidado con los SVG. Aunque se muestren como imágenes, son documentos XML que pueden contener scripts. Si no los necesitas, exclúyelos de los formatos admitidos.

Limita el tamaño y la frecuencia de las subidas, y protege los endpoints con autenticación, para evitar abusos que llenen tu almacenamiento o disparen tu factura.

Rendimiento y experiencia de usuario

Algunas decisiones tienen un impacto notable en la percepción de velocidad de la aplicación.

Elige formatos modernos al exportar desde el canvas. WebP ofrece una compresión mucho mejor que JPEG con una calidad similar y tiene soporte universal en los navegadores actuales. AVIF comprime todavía más, aunque la exportación desde canvas no está disponible en todos los navegadores, por lo que conviene comprobar el resultado y recurrir a WebP o JPEG como respaldo:

async function exportarConRespaldo(canvas: HTMLCanvasElement): Promise<Blob> {
    const intentar = (tipo: string) =>
        new Promise<Blob | null>((resolve) =>
            canvas.toBlob(resolve, tipo, 0.8)
        );

    const avif = await intentar("image/avif");
    if (avif?.type === "image/avif") return avif;

    const webp = await intentar("image/webp");
    if (webp?.type === "image/webp") return webp;

    return (await intentar("image/jpeg"))!;
}

Si el navegador no admite el formato solicitado, toBlob devuelve un PNG, por eso se comprueba el tipo real del resultado.

Para imágenes muy grandes, el procesamiento puede bloquear la interfaz durante unos instantes. La API OffscreenCanvas permite mover el trabajo a un Web Worker y mantener la página fluida, usando convertToBlob en lugar de toBlob.

Muestra siempre una vista previa inmediata con URL.createObjectURL y un indicador de progreso durante la subida. Y en las imágenes que muestres después, indica siempre el ancho y el alto, o usa el componente Image de Next.js, para reservar el espacio y evitar saltos de diseño mientras cargan.

Errores comunes y cómo solucionarlos

Al implementar subidas y transformaciones aparecen siempre los mismos problemas.

El primero es olvidar la directiva 'use client' en componentes que usan el widget, el estado o la API del navegador. El síntoma suele ser un error durante el renderizado en el servidor:

Error: useState only works in Client Components. Add the "use client" directive at the top of the file to use it.

La solución es marcar el componente como de cliente o mover la parte interactiva a un componente separado.

El segundo es que la variable de entorno del cloud no esté disponible, lo que impide que CldImage genere las URL. Comprueba que el nombre lleva el prefijo NEXT_PUBLIC_ y reinicia el servidor de desarrollo tras modificar .env.local, porque los cambios no se recargan en caliente.

El tercero es intentar dibujar en el canvas una imagen de otro dominio sin permisos CORS. El navegador marca el lienzo como contaminado y bloquea la exportación:

SecurityError: Failed to execute 'toBlob' on 'HTMLCanvasElement': Tainted canvases may not be exported.

Trabaja con archivos locales obtenidos del input, o sirve las imágenes remotas con las cabeceras CORS adecuadas y el atributo crossOrigin="anonymous".

El cuarto es superar el límite de tamaño de las Server Actions, que produce un error indicando que el cuerpo excede el máximo permitido. Reduce la imagen en el cliente antes de enviarla o ajusta bodySizeLimit en la configuración.

El quinto es que la imagen recortada aparezca girada. Casi siempre se debe a la orientación EXIF; usa createImageBitmap con imageOrientation: 'from-image' como en el ejemplo.

El sexto es que las subidas sin firma funcionen en desarrollo y se conviertan en un agujero en producción. Revisa los presets antes de publicar la aplicación y usa siempre subidas firmadas con un endpoint autenticado.

Conclusiones

Subir, recortar y redimensionar imágenes es una necesidad común en casi cualquier aplicación web, y Next.js ofrece todas las piezas para resolverla con dos enfoques complementarios.

Con Cloudinary y el paquete next-cloudinary, la subida se resuelve con el componente CldUploadWidget y las transformaciones con CldImage, que traduce propiedades como el ancho, el alto, el modo de recorte y la gravedad automática en URLs que el servicio procesa bajo demanda. Es la opción más potente cuando se necesitan muchas variantes, recorte inteligente y entrega optimizada, siempre que se protejan las subidas mediante firmas generadas en el servidor.

Con la API de Canvas, el navegador puede recortar y redimensionar las imágenes antes de enviarlas, reduciendo el peso de las subidas, eliminando metadatos sensibles y evitando dependencias externas. Combinada con una librería de recorte interactivo y una Server Action o una subida directa a un almacenamiento de objetos, permite construir un flujo completo con pocas líneas de código.

En ambos casos, la seguridad y el rendimiento deben formar parte del diseño desde el principio: validar en el servidor, no confiar en los datos del cliente, elegir formatos modernos y cuidar la experiencia de usuario con vistas previas y dimensiones reservadas. Con estas pautas, las imágenes dejarán de ser un problema y se convertirán en una parte rápida y fiable de tu aplicación.