The Next Craft × Crafter Station · 12h in-person hackathon · Lima · Bogotá · Guatemala · Arequipa · El Salvador

Thoughts

Por qué terminé construyendo mi propio UploadThing

Cómo un proyecto de subida de PDFs, un domingo de Ship or Sink y la costumbre de repetir siempre los mismos pasos terminaron convirtiéndose en UploadX.

Lo que fui construyendo: UploadX · SDK y dashboard · MinIO · Clerk · Next.js · Claude Code


Todo esto empezó con un proyecto en el que tenía que subir documentos, PDFs concretamente, y en ese momento nosotros usábamos bastante UploadThing. Y la verdad es que UploadThing es un producto muy bueno, sobre todo porque el SDK es facilísimo de integrar en Next.js: defines un file router, pones un componente y ya está funcionando. El problema es que tiene créditos limitados y es de paga, así que en algún momento te topas con ese límite. Y el otro problema, que para mí terminó siendo más importante, es que realmente funciona bien para Next.js y no tanto para otros lenguajes. Si quieres usarlo desde Golang, por ejemplo, se puede, pero no es tan fácil como debería.

Entonces la idea que se me vino a la mente fue: qué tal si nosotros creamos nuestro propio clon de eso.

Un domingo de Ship or Sink

En Crafter teníamos los domingos un programa que se llamaba Ship or Sink, y la dinámica era que durante ese domingo intentábamos crear una herramienta y shipearla. La gracia era poder publicar después un post en LinkedIn o en Twitter diciendo “oye, en tres horas construimos esto, miren”, con una demo visual y todo. Yo a veces participaba y a veces hacía de organizador, pero en una de esas decidí aprovechar el espacio para construir esto.

Ese día quedó más o menos armado: agregué la página web, creé la API para poder subir y gestionar archivos, hice el dashboard y agregué una página de documentación bastante sencilla. Con eso publiqué el post en LinkedIn y salió bien.

Lo interesante es que el formato te obliga a decidir rápido qué es lo mínimo que hace que el producto se entienda, y en este caso eso fue: una API que recibe archivos, un lugar donde verlos y algo de documentación para que alguien más pueda probarlo. Todo lo demás vino después, cuando ya lo estaba usando de verdad.

MinIO por detrás, porque S3 no era opción

Antes de esto nosotros ya habíamos intentado usar MinIO directamente en los proyectos, pero MinIO tiene el problema de que es un poco más complejo de configurar de lo que uno quisiera para cada aplicación nueva. Entonces yo dije: en lugar de configurarlo cada vez, podemos usar MinIO y envolverlo en esta aplicación, que UploadX funcione con MinIO por detrás y que el proyecto que lo consume no tenga que saber nada de eso.

S3 era la otra alternativa obvia, pero S3 es de paga y no queríamos usar eso. Como ya teníamos un VPS, decidí utilizar MinIO, que además es open source y es básicamente muy parecido a S3, así que la API se sentía familiar desde el primer momento y no había que preocuparse por créditos.

También le agregué una base de datos PostgreSQL para tener toda la metadata directamente a la mano: qué archivos existen, de qué aplicación son, cuánto pesan, qué tipo son y cuándo se subieron. Y para los casos en los que había que subir archivos directamente sin pasar por mi API, usé URLs prefirmadas, que es lo que permite que el archivo viaje del cliente al almacenamiento sin tener que atravesar el servidor.

Eso al final es lo que define cómo funciona una subida. El cliente le pide permiso a la API, la API responde con una URL prefirmada que sirve para ese archivo concreto y por una hora, el cliente sube el archivo directamente a MinIO y después confirma que terminó, y recién ahí se guarda la metadata en PostgreSQL. La ventaja de hacerlo en dos fases es que el archivo nunca pasa por mi servidor, así que da igual si pesa doscientos megas.

flowchart LR
    C[Tu aplicación] -->|1 · pide permiso| A[UploadX API]
    A -->|2 · URL prefirmada| C
    C -->|3 · sube el archivo| M[MinIO]
    C -->|4 · confirma| A
    A --> P[(PostgreSQL · metadata)]

La autenticación terminó siendo por organizaciones

Después de ese primer domingo le agregué autenticación con Clerk, porque creo que funciona muy bien y porque tiene organizaciones. Eso me venía muy bien para este caso concreto, porque las organizaciones me permitían tener los archivos separados por organización de forma natural, sin tener que inventar yo un modelo de permisos desde cero.

Encima de eso agregué API tokens, para que desde un proyecto puedas subir información simplemente con el token y la URL base, sin arrastrar credenciales de almacenamiento a ninguna parte. Los tokens se guardan hasheados con SHA-256 y se muestran una sola vez cuando los creas, así que si pierdes uno no hay manera de recuperarlo, solo de crear otro.

La forma en que quedó organizado al final es que una organización de Clerk es un equipo, cada equipo tiene sus aplicaciones, cada aplicación tiene su propio bucket, y los tokens pertenecen a una aplicación concreta. Eso hace que un token que se te escape solo pueda tocar los archivos de esa aplicación y de ninguna otra, y también es lo que permite que el dashboard te muestre exactamente los archivos del proyecto en el que estás y no una lista revuelta de todo.

Cómo quedó organizado el proyecto

Todo vive en un monorepo con workspaces de Bun y Turborepo por encima, básicamente porque el dashboard y los SDKs comparten cosas y no tenía sentido tenerlos en repositorios separados. El esquema de base de datos, por ejemplo, lo define el SDK y lo consume el dashboard, así que si cambia una tabla se entera todo el mundo a la vez.

uploadx/
├── packages/
│   ├── uploadx/   → @uploadx-sdk/core    el SDK, los tipos y el esquema
│   ├── react/     → @uploadx-sdk/react   componentes y hooks
│   └── cli/       → @uploadx-sdk/cli     el comando uploadx
└── apps/
    ├── dashboard/  Next.js · Clerk · la API · los docs
    └── demo/       una app de ejemplo que consume los SDKs

Para construir los paquetes uso tsup, que me deja sacar ESM y CommonJS a la vez junto con los tipos sin tener que pelearme con la configuración, y para formato y linting uso Biome en lugar de ESLint más Prettier, principalmente porque es una sola herramienta y es bastante rápida. Y para desarrollo local hay un Docker Compose que levanta MinIO y PostgreSQL, así no necesitas nada instalado en la máquina para empezar a trabajar.

Los SDKs vinieron después

Con todo eso ya funcionando, le agregué los SDKs. Quedaron divididos en dos: uno es el core, que tiene toda la API embebida en tipos, y el otro es el de React, que tiene los componentes y los hooks para que se pueda usar fácilmente desde una aplicación.

El core no exporta una sola cosa sino varias rutas separadas, y eso fue a propósito. Está @uploadx-sdk/core/server para el file router y la API del servidor, /client para la subida con progreso, /next para los route handlers de Next.js y /db para el esquema de Drizzle. La razón es que si importas la parte de cliente no quieres que se te venga MinIO ni el driver de PostgreSQL detrás, y separarlo así hace que cada parte arrastre solamente lo suyo. Por lo mismo, Next.js, Drizzle y pg están como dependencias opcionales: si tu proyecto no usa Next.js, el SDK igual funciona.

La parte que más me gusta de cómo quedó es el file router, que es donde declaras qué acepta tu aplicación:

export const fileRouter = {
  imageUploader: f({ image: { maxFileSize: "4MB", maxFileCount: 5 } })
    .middleware(({ req }) => ({ userId: "user_123" }))
    .onUploadComplete(({ metadata, file }) => {
      console.log("Subió", metadata.userId, ":", file.name);
    }),
} satisfies FileRouter;

Ese objeto es el que después tipa todo lo demás. El SDK de React no exporta un UploadButton genérico, sino un generateUploadButton<AppFileRouter>() que te devuelve un componente que ya sabe qué endpoints existen en tu proyecto, así que si escribes mal el nombre de un endpoint te lo dice TypeScript y no te enteras en producción.

Aquí es donde la referencia a UploadThing es más evidente, y fue a propósito. Al principio la idea era literalmente hacer un clon, porque salió del formato de Ship or Sink y eso te empuja a partir de algo que ya existe. Después ya lo fui tuneando a mi manera, pero mantuve la forma de la API muy parecida por una razón bastante práctica: si uno de nuestros proyectos ya estaba usando el otro producto, podíamos reemplazarlo muy fácilmente porque era muy similar. Y para cualquier persona que ya venía de ahí, cambiarse no significa reescribir la aplicación.

La otra diferencia importante es que UploadX es open source y self-hosted. El otro es privado, y acá la idea era que tú pudieses copiarlo, ponerlo en un contenedor, en AWS, en un VPS o donde gustes, y autohostearlo tú mismo. La idea era que fuese gratuito también.

La documentación también terminó siendo parte del producto

La página de documentación empezó siendo la cosa sencilla que hice ese domingo y con el tiempo se fue volviendo más importante, porque es literalmente lo primero que ve alguien que quiere integrar esto. Está escrita a mano dentro del dashboard, con un componente de bloque de código que resalta la sintaxis y tiene su botón de copiar, que es lo mínimo que esperas cuando vas a copiar diez snippets seguidos.

Después le agregué un botón que copia la página entera como Markdown, y ese salió de una necesidad muy concreta: cada vez que integraba UploadX en un proyecto nuevo, lo que yo hacía era copiar la documentación y pegársela al agente para que supiera cómo usarlo. Así que en lugar de copiar sección por sección, un botón que te deja todo el documento en el portapapeles.

Para la API HTTP hice algo distinto, porque describirla a mano en prosa se desactualiza sola. Escribí un documento OpenAPI que describe todos los endpoints, los esquemas y los modos de autenticación, lo sirvo desde la propia API y lo renderizo con Scalar, que te da una referencia navegable con un playground para probar las peticiones ahí mismo. Eso sirve para quien quiera usar UploadX desde un lenguaje para el que no hay SDK, que era justamente una de las cosas que me molestaban del producto original.

Por qué después necesitaba un CLI

Lo que pasaba era que cada vez que yo quería agregar UploadX a un proyecto nuevo tenía que hacer siempre lo mismo: entrar al dashboard, crear la aplicación, obtener los tokens, pasarle los tokens al proyecto y después copiar el markdown de la documentación y enviárselo a mi agente para que supiera cómo integrar UploadX. Cinco pasos manuales, siempre iguales, antes de escribir la primera línea de código del proyecto de verdad.

Entonces dije: esto sería más fácil si tenemos un CLI que pueda hacer todo eso, y una skill para que el agente sepa usarlo de forma autónoma. Así yo me autentico una sola vez en la máquina y a partir de ahí Claude Code puede crear la aplicación, sacar el token, dejarlo en el .env.local y seguir trabajando sin que yo tenga que abrir el navegador. Incluso puede hacer pruebas directamente: revisar si hay archivos o no, probar una subida, ese tipo de verificaciones que antes tenía que hacer yo a mano para confirmar que la integración estaba bien.

Y obviamente también me sirve a mí, porque si quiero revisar algo rápido no tengo que entrar al dashboard, simplemente listo los archivos desde la terminal.

La parte de autenticación fue la que más pensé, porque un CLI no puede tener una cookie de sesión como el navegador. Terminé usando el device flow de OAuth contra Clerk, que es el mismo mecanismo que usan la CLI de Vercel o la de GitHub: el comando imprime un código, tú lo apruebas en el navegador y recién entonces guarda las credenciales en tu máquina. Lo bueno de ese flujo es que el navegador no tiene que estar en la misma computadora, así que funciona igual por SSH o dentro de un contenedor. Para CI, donde no hay ningún navegador ni ninguna persona esperando, se usa el token de aplicación de siempre, que alcanza para trabajar con archivos aunque no permita crear aplicaciones nuevas.

Del lado del servidor eso significó que la API pasó a tener tres tipos de visitante: el navegador con su sesión de Clerk, el CLI con su token de OAuth y el SDK con su token de aplicación. En lugar de que cada endpoint resolviera eso por su cuenta, hice una sola función que identifica quién está llamando y devuelve siempre la misma respuesta, y todos los endpoints la usan. Eso no solo simplificó el código, sino que al concentrar la autenticación en un solo lugar aparecieron un par de huecos que llevaban ahí un tiempo, como un endpoint que devolvía la lista de archivos de una aplicación con solo pasarle el ID.

El CLI en sí es bastante aburrido por dentro, y creo que eso está bien: es un cliente HTTP y nada más. No lleva MinIO ni el driver de PostgreSQL, solo commander para los comandos, clack para las preguntas interactivas y poco más. Guarda las credenciales en ~/.uploadx/config.json con permisos restringidos, y las organiza por perfiles, así que puedes tener a la vez la instancia hosteada y una self-hosted tuya y cambiar entre ellas con una bandera. Y como cada instancia le dice al CLI cuál es su propia configuración de OAuth, alguien que se autohostee UploadX puede usar el mismo CLI contra su servidor sin tener que forkearlo.

Dónde lo estoy usando

Ahora mismo lo tengo en varios proyectos, y me sirve que sean bastante distintos entre sí porque cada uno usa una parte diferente.

ProyectoQué guarda ahí
WAPIArchivos, imágenes y documentos que llegan desde WhatsApp
KekitoEl bot de WhatsApp que usa WAPI por detrás, pero que tiene su propio gestor de archivos sobre UploadX
KoehonPDFs de libros, literalmente
Flores AmarillasLas imágenes de los productos de una tienda con pagos por Culqi

Lo que me gusta de esa mezcla es que confirma la razón por la que lo hice. En un caso son archivos que llegan de afuera y no controlas ni el tamaño ni el tipo, en otro son documentos grandes, y en otro son imágenes de producto que tienen que cargar rápido en una tienda. Si tuviera que montar una instancia nueva de MinIO y configurarla para cada uno de esos proyectos, probablemente terminaría usando otra cosa.

Lo que me llevo de construirlo

Si vamos a construir un producto open source que va a ser usado por mucha gente, yo recomendaría basarnos en productos actualmente funcionales de empresas de Silicon Valley, porque la verdad es que hacen muy buenos productos. Yo me baso un poco en mi experiencia trabajando en Clerk y también en lo que vi en el producto que cloné al inicio, y hay cosas que ahí están resueltas muy bien y que uno puede aprender simplemente mirando cómo lo hicieron.

De todo eso, lo que a mí me parece importante es tener muy buena documentación, y hoy en día existen formas de documentar APIs como Scalar que sirven muchísimo. También tener un buen dashboard para poder manipular la información, y autenticación, sea cual sea la que vayas a usar. Y sin lugar a dudas documentación que pueda copiarse en Markdown, fácil de usar por agentes, que en mi caso fue exactamente lo que el CLI terminó reemplazando. Igual tener componentes y SDKs para los distintos lenguajes o frameworks, porque eso ayuda muchísimo a integrar tu producto con otros productos, y si es posible contar también con skills para que la integración sea más fácil todavía.

Creo que hoy en día el hecho de que un producto esté orientado a una muy buena experiencia de desarrollo, y a una muy buena experiencia de desarrollo agéntico, es el camino a seguir. Para eso ayuda tener un MCP para conectarlo con tus agentes, o incluso mejor un CLI, y si el CLI soporta múltiples perfiles puedes gestionar varias instancias del producto sin tener que reconfigurar nada cada vez.

Y como suele pasar, nada de esto estaba planeado desde el principio. UploadX empezó siendo un clon hecho en un domingo para poder publicar un post, y las partes que tiene hoy fueron apareciendo porque me las fui encontrando mientras lo usaba: la autenticación cuando necesité separar proyectos, los SDKs cuando me cansé de escribir el mismo código de subida, la referencia de la API cuando quise usarlo desde algo que no fuera Next.js, y el CLI cuando me di cuenta de que estaba repitiendo los mismos cinco pasos cada vez que empezaba algo nuevo.

← all thoughts