Saltar al contenido
ROXEX Labs
Casos de estudio

Caso de estudio

Producto propio

roxex.es: una web estática con CRM propio en Cloudflare

Sector
Producto propio · Web y sistema interno
Ubicación
Murcia, España
roxex.es

Resultados

TBT de /servicios · Lighthouse 12 móvil, mediana de 3; después medido el 10/11/2026
750 → 153 ms
TBT de /contact · mismo método
370 → 191 ms
TBT de un artículo del blog · mismo método
440 → 145 ms
TBT de la home · mismo método, aún por encima de 200 ms
640 → 364 ms

El problema

Necesitábamos una web que sirviera cientos de páginas (servicios, blog, guías y páginas locales) sin un servidor que mantener, y que a la vez hiciera las partes que sí necesitan estado: recibir formularios de contacto y guardarlos en un CRM propio con la prueba del consentimiento, vender productos digitales, dar a cada comprador su área privada y tener un panel interno para trabajar los contactos. Somos un estudio pequeño, así que todo tenía que poder operarse sin guardias. Y una regla de la casa: nada de datos inventados en la web, comprobado de forma automática.

Qué construimos

Next.js con export estático: cada página se genera como HTML al compilar y Cloudflare Pages la sirve tal cual. Lo dinámico vive en Pages Functions bajo functions/api: contacto, captación de emails, bajas, métricas propias con consentimiento, pago con Stripe y su webhook, el área de compradores con enlace de acceso y la API del panel interno. Los datos están en Cloudflare D1, con el esquema en migraciones SQL numeradas, y las descargas protegidas en R2. El panel /admin es una aplicación de cliente dentro del mismo export que habla con esa API. npm run verify encadena el chequeo de tipos, la build y varias auditorías del HTML exportado, y lo ejecutamos antes de publicar; el script de despliegue no lo exige por sí solo.

Stack técnico
Next.js 16React 19TypeScriptTailwind CSS v4Cloudflare PagesPages FunctionsCloudflare D1Cloudflare R2StripeResendClaude (Anthropic)WranglerNode.js

Cómo está hecho

Export estático
next.config.ts fija output: "export": en producción no hay servidor de Node, solo HTML, CSS y JavaScript generados al compilar. Un script posterior a la build (flatten-segment-prefetch.mjs) copia a su nombre plano los ficheros de precarga de rutas que una build en Windows deja dentro de carpetas, para que el enrutador del cliente no reciba un 404 al pedirlos.
Funciones solo donde hay estado
Las Pages Functions de functions/api cubren contacto, captación de emails, bajas con confirmación en /baja, métricas propias, pago y webhook de Stripe, el área de compradores, descargas protegidas y la API del panel. Un middleware añade cabeceras de seguridad restrictivas a todas las respuestas de la API; como ninguna función devuelve HTML, su política de contenidos lo niega todo.
CRM en D1
Una base de datos SQLite en Cloudflare D1 con el esquema en doce migraciones SQL numeradas (schema/0001 a 0013, sin 0012): contactos con la prueba del consentimiento, línea de tiempo de actividad, plantillas de email, procesos por cliente, usuarios del panel, compras, sesiones de compradores y el módulo de campañas. Las migraciones se aplican a mano con Wrangler.
Formularios que no pierden mensajes
/api/contact solo responde que todo ha ido bien después de escribir el contacto y su actividad en D1, en un lote atómico. Los avisos posteriores (Telegram, n8n y el acuse de recibo por email con Resend) son de mejor esfuerzo y nunca hacen fallar la petición. El texto del consentimiento que se guarda como prueba es una constante del servidor, no el que envía el navegador.
Panel interno con roles
Tres roles (lectura, edición y administración) y una sesión firmada que el servidor revisa en cada petición: el rol se lee de la base de datos cada vez, así que bajarle el rol a alguien surte efecto al momento, y la sesión deja de valer si cambia su contraseña o se borra el usuario. Las contraseñas se guardan con PBKDF2 usando Web Crypto, sin dependencias. Tras varios intentos fallidos el acceso se bloquea de forma progresiva y cada intento queda registrado.
Módulo de campañas con IA revisada
Dentro del panel, un módulo de gestión de campañas y entregables. La lista blanca de columnas editables vive en un fichero de tipos que compilan tanto la web como las funciones, y la API construye cada INSERT y UPDATE solo con esas claves y con valores parametrizados. Un asistente con Claude redacta borradores y extrae briefs, pero nunca escribe en la base de datos: la interfaz aplica el resultado solo tras revisión humana.
Ventas y área de compradores
El pago se hace en Stripe Checkout. El webhook acepta solo avisos con firma válida y entrega el enlace de descarga de forma idempotente por sesión de pago. Los compradores entran con un enlace de un solo uso, y de enlaces y sesiones solo se guarda su huella.
Auditorías antes de publicar
npm run verify encadena el chequeo de tipos de la web y de las funciones, la coherencia del menú de servicios, la build y cuatro auditorías del HTML exportado (que cada fichero de public/ esté en out/ con el mismo tamaño, enlaces internos y metadatos, el escapado de los artículos del blog y las afirmaciones sin respaldo), más una del contraste de color de los estilos según WCAG. Se detiene en el primer fallo.

Decisiones y contrapartidas

Qué decidimos, por qué y qué cedimos a cambio.

  1. Export estático en lugar de un servidor de Next.js.

    Por qué:
    Las páginas de contenido no cambian entre publicaciones: se sirven como HTML desde la red de Cloudflare y no hay un servidor que actualizar ni vigilar.
    A cambio:
    Cada cambio de contenido exige una build completa y un despliegue, todo lo dinámico tiene que ir en funciones aparte y no hay optimización de imágenes de Next.js (images.unoptimized).
  2. Un CRM propio en D1 en lugar de una herramienta de CRM externa.

    Por qué:
    Los formularios escriben en una base de datos de la misma plataforma que la web, con la prueba del consentimiento versionada junto a cada contacto, y el panel muestra exactamente lo que hay guardado.
    A cambio:
    El panel, el esquema y sus migraciones son nuestros: hay que mantenerlos. Las migraciones se aplican a mano, fichero a fichero, y el orden lo indican sus propios comentarios, no un sistema de migraciones.
  3. Responder «enviado» solo cuando el mensaje está guardado.

    Por qué:
    Un mensaje de contacto perdido es peor que un aviso que llega tarde, así que la escritura en D1 es lo único que decide la respuesta y los avisos van después.
    A cambio:
    Si la base de datos no responde, el formulario devuelve un error y quien escribe tiene que reintentarlo o usar otro canal.
  4. El middleware de cabeceras en functions/api y no en la raíz de functions.

    Por qué:
    En la raíz se ejecutaba en todas las peticiones, así que Pages enviaba también las páginas estáticas a través de las funciones y se saltaba para ellas las redirecciones y cabeceras de public/.
    A cambio:
    Las páginas estáticas no reciben esas cabeceras de la API: las suyas dependen de public/_headers, que se mantiene aparte.
  5. Quitar GSAP y animar con la Web Animations API del navegador.

    Por qué:
    GSAP iba en el JavaScript de cada página interior para animaciones de entrada sencillas, y los artículos arrancaban invisibles hasta que el script se ejecutaba. Quitarlo ahorró unos 26 KiB comprimidos por página.
    A cambio:
    Un módulo propio de animación que mantener, sin las líneas de tiempo complejas ni las animaciones ligadas al scroll que da GSAP.
  6. Un detector automático de afirmaciones sin respaldo sobre el HTML exportado.

    Por qué:
    La web llegó a tener testimonios de relleno, recuentos de clientes y métricas sin informe. Revisar el resultado final, y no cada componente, cubre cualquier sitio del que salga el texto.
    A cambio:
    Solo reconoce las fórmulas que ya conocemos: una cifra inventada con otras palabras pasa. Y cada coincidencia legítima necesita una excepción escrita con su motivo.

Límites y qué haríamos distinto

  • Las cifras de rendimiento son de laboratorio (Lighthouse en un equipo local, servido desde una copia de la build) y no hay datos de campo de usuarios reales que las confirmen. La home sigue por encima de los 200 ms de TBT que Lighthouse considera rápidos.
  • No hay tests automáticos de las funciones del servidor: las protegen el chequeo de tipos y las auditorías del export, que no ejecutan ninguna función.
  • Las migraciones de D1 se aplican a mano; el repositorio no usa el sistema de migraciones de D1, así que no registra cuáles están aplicadas en cada entorno.
  • Desarrollamos en Windows dentro de una carpeta sincronizada con OneDrive: hace falta el script que corrige la build y medir siempre sobre una copia fuera de esa carpeta.
  • El detector de afirmaciones lee el HTML exportado, no los textos que llegan solo con JavaScript, como la versión en inglés de los artículos.

Estado

En producción en roxex.es. La primera versión se publicó el 1 de abril de 2026, según el historial de cambios de la web, y desde entonces se desarrolla en el mismo repositorio.

Este caso está escrito leyendo el repositorio de roxex.es a 10 de noviembre de 2026 (configuración de Next.js, funciones, esquema SQL, autenticación del panel y scripts de auditoría). Las cifras de rendimiento son las del build log enlazado abajo, con su método. El código no es público.

Abrir roxex.es

Sigue leyendo

¿Tienes un proyecto similar?

Cuéntanos qué necesitas y te diremos si encajamos y cuáles serían los siguientes pasos.