¿Por qué existe?
Qué ganamos con cada módulo de la librería frente a mantener copias por sistema o adoptar librerías genéricas.
Antes de esta librería, cada sistema FESA mantenía su propia copia de los mismos componentes: más de 40.000 líneas duplicadas entre FesaStore, fesa.com.pa, Fesa ID y el starter. El caso extremo: la librería de formularios existía dos veces (~17.000 y ~19.000 líneas) divergiendo un poco más cada mes, y el hook useInView existía en cuatro copias byte-idénticas.
La alternativa de adoptar librerías genéricas del ecosistema se evaluó y se descartó. Esto es lo que ganamos, módulo por módulo.
Los principios (aplican a toda la librería)
- Un fix llega a todos los sistemas. Un bug se arregla una vez, con test de regresión, y las apps lo reciben con un bump de versión — no se re-arregla (o se olvida) copia por copia.
- El caso FESA es el default, no una configuración. En una librería genérica cada módulo repite el mismo cableado; aquí el comportamiento correcto viene de fábrica.
- Las versiones las decidimos nosotros. Tags pineados: ninguna major ajena nos obliga a migrar 19 listados en cuatro apps a la vez.
- Consistencia para el usuario final. La misma tabla, los mismos formularios, los mismos correos con la misma marca en todos los sistemas — menos que reaprender, menos bugs "que en el otro sistema no pasan".
- Un solo conocimiento. Quien aprende un componente en un proyecto lo sabe usar en los seis; esta documentación cubre el 100% de la superficie porque la superficie es nuestra.
DataTable — ./datatable
El problema que resolvía: cuatro copias de ~23.000 líneas del mismo motor de tablas, con bugs que se arreglaban en una copia y reaparecían en otra.
Qué ganamos:
- Control total de los renders — la razón original por la que se escribió a mano: store zustand por instancia y celdas memoizadas, seleccionar 1 fila de 100 re-renderiza 1 fila. Las librerías genéricas de tablas re-renderizan más de lo que un listado admin necesita.
- Un listado nuevo son ~15 líneas con
useSmartTable(antes ~70-300): paginación en servidor, URL compartible, debounce, anti-race y errores visibles vienen de serie. - 34 tests del motor (races, respuestas stale, corrección de página vacía) protegen los 19+ listados de las cuatro apps a la vez.
Formularios — ./forms
El problema: ~36.000 líneas duplicadas entre fesa.com.pa y el starter, con las dos copias divergiendo en estructura de archivos. En 6 meses habrían sido incompatibles.
Qué ganamos:
- 48 campos listos — de texto y moneda a firma en canvas, crop de imagen, recurrencia, horarios, IBAN y tarjeta de crédito — con validación integrada a react-hook-form. Un formulario nuevo se arma en minutos, no en días.
- Cero dependencias externas: el rich text, la firma, el crop y el emoji picker son implementaciones propias. Ninguna librería de terceros nos marca el paso ni engorda el bundle.
- Un solo contrato (
control+name+ textos por props en español): todos los formularios del ecosistema se ven y se comportan igual — el usuario que aprendió a llenar uno los sabe llenar todos.
UI — ./ui
El problema: las primitivas shadcn (botón, input, diálogo, select…) copiadas en los seis repos, cada una derivando por su lado (en dos satélites ya solo 2 archivos seguían byte-idénticos).
Qué ganamos: un set mantenido con los extras FESA (PasswordInput con aria-labels en español, Combobox async contra server actions), que además hace autocontenidos a los formularios y al datatable. Las apps satélite (tracking, transfer) dejan de mantener ~2.900 líneas de primitivas por su cuenta.
Motion y hooks — ./motion + ./hooks
El problema: useInView en cuatro copias idénticas; tres versiones distintas de AnimatedSection con comportamientos diferentes bajo el mismo nombre.
Qué ganamos: animaciones de entrada sin librerías de animación (CSS puro + rAF, cero peso en el bundle) que respetan prefers-reduced-motion — accesibilidad que solo una de las copias tenía y ahora tienen todos. useIsMobile sin flash de SSR.
Utilidades — ./utils
El problema: cada app reinventaba (distinto) el manejo de errores, la paginación, el slug, el contraste y la sanitización.
Qué ganamos:
AppError+toActionError: los errores del driver de base de datos nunca llegan crudos al cliente — mismo contrato de error en todos los sistemas.- Contraste WCAG 2.1 con tests: accesibilidad medible (lo usa el editor de tarjetas para avisar si un diseño no se lee).
sanitizeHtmlcon allowlist: una sola política de sanitización contra XSS.- Formato es-PA/América-Panamá (moneda, fechas) como constante de la casa, no como decisión por archivo.
- Puertos de infraestructura (
IStorageService,IEmailService,ICache,IRateLimitService): cambiar de proveedor no toca a los consumidores.
Seguridad — ./security
El problema: el WAF existía en dos copias (FesaStore y fesa.com.pa) con reglas que evolucionaban por separado.
Qué ganamos: un WAF por reglas con puntuación (SQLi, XSS, path traversal, command injection) con su suite de tests de 413 líneas que viaja con la librería. Una regla nueva contra un ataque protege todos los puntos de entrada de texto libre del ecosistema a la vez — y al usuario final, aunque nunca lo vea.
Email — ./email
El problema: cuatro variantes del sistema visual de correos, cada una con la marca y la URL quemadas ("Fesa ID", "FesaStore"…), imposibles de compartir.
Qué ganamos: el theme de correo que FesaStore ya usa en producción para sus 16 correos — EmailLayout + bloques (MetaBox, CtaButton, CodeBox, AlertBox…) sobre @react-email. Todos los sistemas envían correos con la misma cara; una mejora de legibilidad llega a todos los buzones.
Storage — ./storage
El problema: tres variantes del adapter de Cloudflare R2 — dos de ellas con contratos incompatibles (uploadUrl vs url) esperando a romper algo en silencio.
Qué ganamos: un solo adapter con URLs prefirmadas detrás de IStorageService. El SDK de AWS es peer opcional: solo lo instala la app que lo usa.
Auth — ./auth
El problema: cada herramienta interna nueva reinventaba el arranque de autenticación (tablas, config de better-auth, seed del primer admin) — y cada copia tomaba decisiones de seguridad por su cuenta.
Qué ganamos: el patrón de la casa en una llamada: registro público cerrado, cuentas creadas por un administrador, rate limit en login y roles parametrizables. Las decisiones de seguridad se toman una vez y viajan con la librería (ej. el issuer que better-auth 1.5 exige para cuentas credenciales ya viene resuelto).
Lo que igual no reinventamos
La apuesta nunca fue "escribir todo desde cero": por debajo usamos primitivas probadas (Radix para accesibilidad, react-hook-form, ExcelJS). La apuesta fue ser dueños de la capa que se toca todos los días — el estado, la URL, el fetch, los textos y el render — que es donde las librerías genéricas nos costaban control, consistencia y velocidad.
En números
6 sistemas · 10 módulos en un solo paquete versionado · +40.000 líneas duplicadas eliminándose por olas (~36.000 solo con formularios) · 113 tests + CI · un listado o formulario nuevo pasa de días a horas.