node.jsexpresspostgresqlvitetailwindjwtcase-studysaas

Case study: SaaS de gestión con API REST, PostgreSQL y dashboard Vite

El cliente necesitaba una plataforma de gestión administrativa interna. Nada glamoroso en el papel: usuarios, roles, registros, reportes, filtros. Pero el volumen de datos era real — miles de registros por entidad, operadores simultáneos, y una UI que no podía congelarse cada vez que alguien filtraba una tabla.

Me encargaron el proyecto completo: backend API, base de datos, frontend, autenticación y configuración Docker. Este post es el registro técnico de como lo construi, que decisiones tome y por que.

Qué necesitaba el cliente

Un dashboard administrativo para gestionar su operación interna. Los operadores necesitaban:

El cliente no tenía equipo técnico propio. Necesitaba algo que funcionara, que pudiera mantener con documentación básica y que no requiriera un DevOps dedicado para desplegarlo.

La decisión de stack

Antes de escribir una línea de código, definí dos restricciones:

  1. Velocidad de entrega: el cliente tenía un deadline real
  2. Mantenibilidad post-entrega: yo no iba a estar disponible indefinidamente para hotfixes menores

Con eso en mente, el stack se definió solo:

Arquitectura

El proyecto se dividió en dos aplicaciones dentro del mismo repositorio monorepo:

graph TB
    subgraph Cliente
        WEB[apollo/web-app<br/>Vite SPA + Tailwind]
        ADMIN[valkiria<br/>Admin UI]
    end

    subgraph Servidor
        API[apollo/api<br/>Express.js]
        MW[Middleware Layer<br/>JWT Auth + Validation]
        subgraph Capas
            R[routes/]
            C[controllers/]
            SV[services/]
            RP[repositories/]
            M[models/]
        end
    end

    subgraph Base de datos
        PG[(PostgreSQL 13+<br/>driver pg directo)]
    end

    WEB -->|Axios + REST| API
    ADMIN -->|Axios + REST| API
    API --> MW
    MW --> R
    R --> C
    C --> SV
    SV --> RP
    RP --> PG

apollo/api — el servidor Express. Organizado en capas con aliases de módulos (@controllers, @services, @repositories, @models, @routes, @utils, @configs) para evitar el infierno de imports relativos (../../../). Cada capa tiene responsabilidad única: los controllers manejan request/response, los services contienen lógica de negocio, los repositories ejecutan queries.

apollo/web-app — la SPA principal. Components de React organizados por feature, services de Axios para comunicación con la API, GridJS para las tablas, Tailwind para estilos.

valkiria — companion app de administración. Misma arquitectura de frontend, diferente scope de permisos y vistas.

La separación en dos frontends no fue capricho arquitectónico — el cliente tenía dos tipos de usuarios con interfaces completamente distintas y no había razón para meter todo en una sola SPA con routing condicional complejo.

Por qué PostgreSQL sin ORM

Esta es la decisión que más me preguntan. La mayoría de proyectos Express usan Sequelize o Prisma por defecto. Yo usé el driver pg directo con queries SQL escritas a mano.

Las razones:

El overhead no valía la pena para este caso. Los ORMs brillan cuando tienes un modelo de datos complejo que cambia frecuentemente, un equipo que no quiere escribir SQL, o necesitas soportar múltiples bases de datos. Este proyecto tenía 5-10 tablas, esquema estable, y yo escribo SQL sin problema.

Las queries quedaron exactamente como las necesitaba. Sin sorpresas de N+1, sin EXPLAIN ANALYZE para entender qué está generando el ORM por debajo. Si quería un JOIN específico con un alias determinado, escribía ese JOIN.

-- Ejemplo de query típica en el proyecto
SELECT
  u.id,
  u.email,
  u.created_at,
  r.name AS role_name,
  COUNT(records.id) AS record_count
FROM users u
JOIN roles r ON r.id = u.role_id
LEFT JOIN records ON records.created_by = u.id
WHERE u.active = true
GROUP BY u.id, u.email, u.created_at, r.name
ORDER BY u.created_at DESC
LIMIT $1 OFFSET $2;

Eso en un ORM requiere configuración extra o terminas en .raw() de todas formas.

El trade-off real: sin ORM no tienes type safety automático en las queries. Las columnas que retorna PostgreSQL son any hasta que las tipas tú. Lo manejé con interfaces TypeScript en la capa de models que mapean explícitamente los resultados. Más trabajo inicial, cero sorpresas en runtime.

El otro trade-off: migraciones manuales. Sin Prisma Migrate o Sequelize migrations, gestioné los cambios de esquema con scripts SQL numerados. Para un proyecto de esta escala es perfectamente manejable. Para un equipo de 10 desarrolladores con schema que cambia cada sprint, reconsideraría.

La decisión fue consciente y apropiada para el contexto. No es la respuesta correcta en todos los casos — es la respuesta correcta para este proyecto.

Frontend Vite + GridJS: el problema de las tablas

El requisito que más me preocupó al principio fue la tabla principal de la plataforma: miles de registros, filtros por múltiples columnas, paginación, ordenamiento. En React puro, renderizar tablas grandes sin virtualización es una receta para una UI que se congela.

GridJS resolvió esto limpio. Es una librería de tablas independiente de framework con soporte para server-side pagination, sorting y filtering built-in. La integración con React es directa:

// Configuración base de GridJS con server-side data
const grid = new Grid({
  columns: [
    { name: 'ID', sort: true },
    { name: 'Nombre', sort: true },
    { name: 'Estado', sort: true },
    { name: 'Fecha', sort: true },
  ],
  server: {
    url: `${API_BASE}/records`,
    then: (data) => data.items.map((row) => [
      row.id,
      row.name,
      row.status,
      formatDate(row.created_at),
    ]),
    total: (data) => data.total,
  },
  pagination: { limit: 25, server: true },
  sort: { multiColumn: false, server: true },
  search: { server: { url: (prev, keyword) => `${prev}?q=${keyword}` } },
});

El resultado: la tabla maneja 5k registros sin degradación perceptible porque solo renderiza lo que está en pantalla. El servidor hace el trabajo pesado — filtering, sorting, pagination — y el cliente solo muestra lo que recibe.

Vite hizo que el DX de desarrollo fuera significativamente mejor que lo que hubiera sido con Create-React-App. HMR instantáneo, arranque en menos de un segundo, y la configuración de code splitting fue trivial para lazy loading de rutas.

Auth JWT desde cero

Decidí no usar Passport.js ni ninguna librería de autenticación de alto nivel. JWT + bcryptjs es suficiente y el código queda completamente visible.

El flujo es estándar pero la implementación importa:

Login: recibir credenciales → buscar usuario → bcrypt.compare() → generar JWT con jsonwebtoken.sign() → devolver token al cliente.

Middleware de auth: en cada request protegida, el middleware extrae el token del header Authorization: Bearer <token>, valida firma y expiración con jsonwebtoken.verify(), y adjunta el payload al objeto req para que los controllers lo consuman.

// Middleware JWT simplificado
const authMiddleware = (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'Token requerido' });

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    req.user = payload;
    next();
  } catch {
    return res.status(401).json({ error: 'Token inválido o expirado' });
  }
};

El trade-off de JWT stateless: no puedes revocar un token antes de que expire sin mantener una blacklist. Para este proyecto, con expiración corta (2 horas) y un flujo de logout que simplemente borra el token del cliente, fue aceptable. Si el cliente hubiera necesitado revocación inmediata — por ejemplo, desactivar un operador en tiempo real — hubiera necesitado una blacklist en Redis o cambiar a sesiones con store.

CORS también requirió configuración explícita para que el frontend pudiera enviar credenciales correctamente:

app.use(cors({
  origin: process.env.FRONTEND_URL,
  credentials: true,
  allowedHeaders: ['Content-Type', 'Authorization'],
}));

Pequeño detalle que rompe el auth si lo configuras mal — mejor entenderlo que dejarlo en un preset que no sabes qué hace.

Testing

Jest + Supertest para los endpoints. No cubrí el 100% — ese no era el objetivo ni el budget del proyecto. Cubrí los flujos críticos: login, auth middleware, endpoints de CRUD principal, y los edge cases de validación que el cliente había reportado como bugs en un sistema previo.

Los tests de integración con Supertest me dieron confianza para refactorizar la capa de services sin romper los contratos de la API.

Lo que aprendí

La decisión de stack es la decisión más cara del proyecto. Una vez que eliges, todo lo que viene después tiene inercia. Invertí tiempo real evaluando antes de escribir código y no me arrepentí.

pg directo escala mejor de lo que parece para proyectos medianos. El argumento de “los ORMs ahorran tiempo” es verdad en equipos grandes con modelos complejos. En un proyecto de este tamaño, el SQL explícito fue más rápido de debuggear y más fácil de optimizar cuando apareció una query lenta.

GridJS es subestimado. No aparece en los tutoriales de React típicos, pero para tablas de datos administrativos es exactamente lo que necesitas. Menos overhead que AG Grid, más funcionalidad que una tabla HTML con useState.

JWT sin blacklist tiene un límite de aplicabilidad. Funciona bien para casos donde la revocación inmediata no es un requisito duro. Si fuera a escalar este proyecto con requerimientos de seguridad más estrictos, agregaría Redis para blacklist de tokens o migharía a refresh tokens con rotación.

El proyecto quedó en producción funcionando. El cliente tiene documentación suficiente para hacer cambios menores. Yo tengo un case study de un full-stack completo donde tomé cada decisión de arquitectura con criterio y la puedo defender.

Eso es lo que un freelance técnico entrega — no solo código que funciona, sino código que tiene razones.

Volver al blog