· Nacho Coll · Tutorials · 11 min de lectura
IPFS + React: Construye un componente descentralizado de subida de archivos
Construye un componente React de subida de archivos con arrastrar y soltar que pinea archivos a IPFS. Tutorial completo con tokens firmados para subidas seguras desde el navegador.

Construir aplicaciones descentralizadas con React e IPFS nunca ha sido tan sencillo. En este tutorial integral, aprenderás cómo crear un componente de subida de archivos listo para producción que pinea archivos directamente a IPFS desde el navegador. Usaremos los tokens firmados de IPFS.NINJA para garantizar subidas seguras sin exponer claves API en tu código frontend.
Al final de esta guía, tendrás un componente de arrastrar y soltar totalmente funcional con progreso de subida, manejo de errores y una gestión de estado adecuada usando TanStack Query.

¿Por qué construir un componente de subida IPFS con React?
Los servicios tradicionales de almacenamiento en la nube como AWS S3 centralizan tus datos y crean dependencia de proveedor. Con IPFS (Sistema de Archivos InterPlanetario), tus archivos se distribuyen a través de una red peer-to-peer, haciéndolos más resilientes y resistentes a la censura.
Beneficios clave de este enfoque:
- Almacenamiento descentralizado: Los archivos se distribuyen en múltiples nodos
- Direccionamiento por contenido: Cada archivo recibe un CID (Identificador de Contenido) único
- Enlaces inmutables: Una vez subidos, los archivos no pueden modificarse sin cambiar el CID
- Accesibilidad global: Los archivos son accesibles desde cualquier gateway IPFS
- Subidas seguras: Usa tokens firmados en lugar de exponer claves API
Si eres nuevo en los conceptos de IPFS, consulta nuestra guía sobre qué es el pinning IPFS antes de sumergirte en el código.
Requisitos previos
Antes de comenzar, asegúrate de tener:
- Conocimientos básicos de React
- Node.js 16+ instalado
- Una cuenta de IPFS.NINJA (regístrate aquí)
- Comprensión de patrones modernos de React (hooks, async/await)
Configurando el proyecto
Primero, creemos un nuevo proyecto React e instalemos las dependencias requeridas:
npx create-react-app ipfs-upload-demo
cd ipfs-upload-demo
npm install @tanstack/react-query react-dropzone lucide-reactUsaremos estas librerías:
- @tanstack/react-query: Para gestión de estado y caché
- react-dropzone: Para funcionalidad de arrastrar y soltar
- lucide-react: Para iconos
Entendiendo los tokens firmados
Antes de construir el componente, es crucial entender por qué usamos tokens firmados en lugar de claves API:
// ❌ Nunca hagas esto - expone tu clave API
const response = await fetch('https://api.ipfs.ninja/upload/new', {
headers: {
'X-Api-Key': 'bws_your_secret_key_here' // ¡Esto es visible en el navegador!
}
});
// ✅ Usa tokens firmados en su lugar
const response = await fetch('https://api.ipfs.ninja/upload/new', {
headers: {
'Authorization': `Signed ${signedToken}` // Seguro para uso del lado del cliente
}
});Los tokens firmados son credenciales con tiempo limitado que generas en el servidor y pasas a tu frontend. No pueden ser usados para acceder a la configuración de tu cuenta u otras operaciones sensibles.
Creando el endpoint de tokens en el backend
Primero, crea un endpoint backend simple para generar tokens firmados. Esto podría ser una ruta API de Next.js, un endpoint Express o una función serverless:
// api/upload-token.js (ejemplo de ruta API Next.js)
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const response = await fetch('https://api.ipfs.ninja/upload/tokens', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': process.env.IPFS_NINJA_API_KEY // ¡Solo en el servidor!
},
body: JSON.stringify({
expiresIn: '1h',
maxUploads: 10,
description: 'React upload component token'
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Token generation failed');
}
res.json({ token: data.token });
} catch (error) {
console.error('Token generation error:', error);
res.status(500).json({ error: 'Failed to generate upload token' });
}
}Construyendo el hook de subida
Creemos un hook personalizado para gestionar la lógica de subida usando TanStack Query:
// hooks/useIPFSUpload.js
import { useMutation, useQueryClient } from '@tanstack/react-query';
const uploadToIPFS = async ({ file, token }) => {
// Convertir archivo a base64 para subida JSON
const base64Content = await new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result.split(',')[1]);
reader.readAsDataURL(file);
});
const response = await fetch('https://api.ipfs.ninja/upload/new', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Signed ${token}`
},
body: JSON.stringify({
content: base64Content,
description: `Uploaded via React: ${file.name}`,
metadata: {
originalName: file.name,
size: file.size,
type: file.type,
uploadedAt: new Date().toISOString()
}
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Upload failed');
}
const data = await response.json();
return {
...data,
originalFile: {
name: file.name,
size: file.size,
type: file.type
}
};
};
export const useIPFSUpload = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: uploadToIPFS,
onSuccess: (data) => {
// Cachear los datos del archivo subido
queryClient.setQueryData(['uploads'], (oldData = []) => [
...oldData,
data
]);
}
});
};Obteniendo tokens de subida
Crea otro hook para obtener tokens firmados desde tu backend:
// hooks/useUploadToken.js
import { useQuery } from '@tanstack/react-query';
const fetchUploadToken = async () => {
const response = await fetch('/api/upload-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Failed to fetch upload token');
}
const data = await response.json();
return data.token;
};
export const useUploadToken = () => {
return useQuery({
queryKey: ['upload-token'],
queryFn: fetchUploadToken,
staleTime: 30 * 60 * 1000, // 30 minutos
retry: 3
});
};Creando el componente de subida
Ahora construyamos el componente principal de subida con funcionalidad de arrastrar y soltar:
// components/IPFSUploader.jsx
import React, { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { Upload, CheckCircle, XCircle, Loader2, Copy, ExternalLink } from 'lucide-react';
import { useIPFSUpload } from '../hooks/useIPFSUpload';
import { useUploadToken } from '../hooks/useUploadToken';
const IPFSUploader = () => {
const [uploadedFiles, setUploadedFiles] = useState([]);
const uploadMutation = useIPFSUpload();
const { data: token, isLoading: isTokenLoading, error: tokenError } = useUploadToken();
const onDrop = useCallback(async (acceptedFiles) => {
if (!token) {
console.error('No upload token available');
return;
}
for (const file of acceptedFiles) {
try {
const result = await uploadMutation.mutateAsync({ file, token });
setUploadedFiles(prev => [...prev, result]);
} catch (error) {
console.error('Upload failed:', error);
}
}
}, [token, uploadMutation]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
maxSize: 10 * 1024 * 1024, // límite 10MB
multiple: true
});
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text);
};
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
if (isTokenLoading) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="w-6 h-6 animate-spin" />
<span className="ml-2">Initializing uploader...</span>
</div>
);
}
if (tokenError) {
return (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center">
<XCircle className="w-5 h-5 text-red-500" />
<span className="ml-2 text-red-700">Failed to initialize uploader</span>
</div>
</div>
);
}
return (
<div className="max-w-2xl mx-auto p-6">
{/* Zona de subida */}
<div
{...getRootProps()}
className={`
border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors
${isDragActive
? 'border-blue-400 bg-blue-50'
: 'border-gray-300 hover:border-gray-400'
}
`}
>
<input {...getInputProps()} />
<Upload className="w-12 h-12 mx-auto text-gray-400 mb-4" />
{isDragActive ? (
<p className="text-blue-600">Drop the files here...</p>
) : (
<div>
<p className="text-gray-600 mb-2">
Drag & drop files here, or click to select
</p>
<p className="text-sm text-gray-500">
Max file size: 10MB
</p>
</div>
)}
</div>
{/* Progreso de subida */}
{uploadMutation.isPending && (
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center">
<Loader2 className="w-5 h-5 animate-spin text-blue-500" />
<span className="ml-2 text-blue-700">Uploading to IPFS...</span>
</div>
</div>
)}
{/* Error de subida */}
{uploadMutation.isError && (
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center">
<XCircle className="w-5 h-5 text-red-500" />
<span className="ml-2 text-red-700">
{uploadMutation.error?.message || 'Upload failed'}
</span>
</div>
</div>
)}
{/* Lista de archivos subidos */}
{uploadedFiles.length > 0 && (
<div className="mt-6">
<h3 className="text-lg font-semibold mb-4">Uploaded Files</h3>
<div className="space-y-3">
{uploadedFiles.map((file, index) => (
<div key={index} className="p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-3">
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5" />
<div>
<p className="font-medium text-gray-900">
{file.originalFile.name}
</p>
<p className="text-sm text-gray-500">
{formatFileSize(file.originalFile.size)} • {file.sizeMB}MB on IPFS
</p>
<div className="mt-2">
<p className="text-xs text-gray-600">CID:</p>
<div className="flex items-center space-x-2 mt-1">
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
{file.cid}
</code>
<button
onClick={() => copyToClipboard(file.cid)}
className="text-gray-400 hover:text-gray-600"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
<a
href={file.uris.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-700"
>
<ExternalLink className="w-5 h-5" />
</a>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
};
export default IPFSUploader;Configurando el componente App
Finalmente, envuelve tu aplicación con el proveedor de TanStack Query:
// App.js
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import IPFSUploader from './components/IPFSUploader';
import './App.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutos
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<div className="App">
<header className="bg-white shadow-sm">
<div className="max-w-4xl mx-auto px-4 py-6">
<h1 className="text-3xl font-bold text-gray-900">
IPFS File Uploader
</h1>
<p className="text-gray-600 mt-2">
Upload files to IPFS with drag-and-drop simplicity
</p>
</div>
</header>
<main className="max-w-4xl mx-auto px-4 py-8">
<IPFSUploader />
</main>
</div>
</QueryClientProvider>
);
}
export default App;Añadiendo estilos CSS
Añade algunos estilos básicos para que el componente se vea pulido:
/* App.css */
.App {
min-height: 100vh;
background-color: #f9fafb;
}
/* Asegurar el estilo adecuado de input de archivo */
input[type="file"] {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}Características avanzadas y optimizaciones
Validación de tipo de archivo
Mejora el dropzone con restricciones de tipo de archivo:
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
onDrop,
maxSize: 10 * 1024 * 1024,
accept: {
'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp'],
'application/pdf': ['.pdf'],
'text/plain': ['.txt'],
},
multiple: true
});
// Mostrar errores de rechazo de archivos
{fileRejections.length > 0 && (
<div className="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-yellow-800 font-medium">Some files were rejected:</p>
<ul className="mt-2 text-sm text-yellow-700">
{fileRejections.map(({ file, errors }) => (
<li key={file.path}>
{file.path}: {errors.map(e => e.message).join(', ')}
</li>
))}
</ul>
</div>
)}Seguimiento del progreso de subida
Para una mejor experiencia de usuario, podrías querer rastrear el progreso de subida de archivos individuales. Si bien la API de IPFS.NINJA no soporta callbacks de progreso directamente, puedes simular el progreso o mostrar el estado de subida por archivo:
const [uploadingFiles, setUploadingFiles] = useState(new Set());
const onDrop = useCallback(async (acceptedFiles) => {
if (!token) return;
for (const file of acceptedFiles) {
const fileId = `${file.name}-${file.size}`;
setUploadingFiles(prev => new Set([...prev, fileId]));
try {
const result = await uploadMutation.mutateAsync({ file, token });
setUploadedFiles(prev => [...prev, result]);
} catch (error) {
console.error('Upload failed:', error);
} finally {
setUploadingFiles(prev => {
const next = new Set(prev);
next.delete(fileId);
return next;
});
}
}
}, [token, uploadMutation]);Mejores prácticas de manejo de errores
Implementa un manejo de errores integral para un componente listo para producción:
const getErrorMessage = (error) => {
if (error.message.includes('401')) {
return 'Upload token expired. Please refresh the page.';
}
if (error.message.includes('413')) {
return 'File too large. Maximum size is 10MB.';
}
if (error.message.includes('429')) {
return 'Too many uploads. Please wait a moment and try again.';
}
return error.message || 'Upload failed. Please try again.';
};Probando tu componente
Para probar la funcionalidad de subida:
- Inicia tu servidor de desarrollo:
npm start - Arrastra algunos archivos al área de subida
- Verifica que los archivos se suban y los CIDs se muestren
- Verifica que las URLs de IPFS funcionen haciendo clic en los iconos de enlace externo
- Prueba escenarios de error (archivos grandes, problemas de red, etc.)
Consideraciones de despliegue
Al desplegar tu uploader IPFS de React:
Variables de entorno
Almacena tu clave API de IPFS.NINJA de forma segura:
# .env.local (para Next.js)
IPFS_NINJA_API_KEY=bws_your_api_key_hereEstrategia de actualización de tokens
Implementa actualización automática de tokens para sesiones largas:
export const useUploadToken = () => {
return useQuery({
queryKey: ['upload-token'],
queryFn: fetchUploadToken,
staleTime: 25 * 60 * 1000, // 25 minutos (los tokens expiran en 30)
refetchOnWindowFocus: true,
retry: 3
});
};Configuración CORS
Asegúrate de que tu backend permita peticiones desde tu dominio frontend.
Optimización de rendimiento
Para mejor rendimiento con archivos grandes o múltiples subidas:
- Usa Web Workers para conversión a base64
- Implementa una cola de subida para limitar subidas concurrentes
- Añade compresión de archivos para imágenes antes de subir
- Cachea los tokens de subida correctamente para evitar peticiones innecesarias
Comparando con otras soluciones
Nuestro enfoque con IPFS.NINJA ofrece varias ventajas sobre las alternativas. Si estás evaluando diferentes servicios de pinning IPFS, consulta nuestra comparación integral en IPFS.NINJA vs Pinata y nuestra guía a los mejores servicios de pinning IPFS.
Para una comprensión más amplia de los métodos de subida IPFS, consulta nuestras guías tutorial de API de subida IPFS y cómo subir archivos a IPFS.
Próximos pasos
¡Ahora tienes un componente de subida IPFS totalmente funcional con React! Aquí algunas formas de extenderlo:
- Añade capacidades de vista previa de archivos
- Implementa operaciones por lotes (eliminar, compartir)
- Crea análisis y métricas de subida
- Añade integración con el sistema de autenticación de tu app
- Construye funciones de organización en carpetas
- Implementa búsqueda y filtrado de archivos
La combinación de la arquitectura de componentes de React y el almacenamiento descentralizado de IPFS crea poderosas posibilidades para construir aplicaciones resilientes y fáciles de usar.
¿Listo para empezar a pinear? Crea una cuenta gratuita — 50 archivos, 1 GB de almacenamiento, 2 GB de ancho de banda/mes. No se requiere tarjeta de crédito.
