Algunas veces, como ingenieros o desarrolladores de software, tenemos que plantearnos si la solución que propusimos —o la que acabamos construyendo— es realmente la más adecuada para nuestro contexto actual. Aquí entra el eterno debate entre no caer en una abstracción prematura y, a la vez, construir un sistema preparado para escalar y crecer. Entre ambas cosas hay una línea gris, muy difusa, que a veces cuesta gestionar.
Hace dos años abrí este blog, montado principalmente sobre un front en Next.js, un CMS con Strapi y una base de datos MySQL. En aquel momento pensaba que Strapi era la mejor opción porque tenía muchas ideas en la cabeza… que, en realidad, sólo estaban en mi cabeza. Lo cierto es que únicamente he usado Strapi para crear categorías, escribir los artículos y alojar sus imágenes. ¿Elegir Strapi entonces fue una abstracción prematura —no a nivel de código, pero sí a nivel de infraestructura— o una decisión escalable y preparada para el futuro? No lo tengo claro, y seguramente esa conversación dé para otro artículo. Pero lo cierto es que, por mis necesidades actuales, he decidido desterrar Strapi y migrar a algo más simple: Notion. No sé si hace dos años tomé una buena decisión; todo tiene sus luces y sus sombras. Lo que sí sé es que hacer hoy el cambio es lo correcto, porque quedarse inmóvil con algo que no te termina de convencer no suele ser el camino acertado.
El CMS ideal es donde ya escribes
Empecemos por una pregunta incómoda: ¿cuánto del CMS que mantenemos usamos de verdad? En mi caso la respuesta era humillante. Detrás de este blog había un Strapi self-hosted en GCP con su propio Cloud Run, una instancia de Cloud SQL con MySQL, un bucket de GCS para las imágenes y un balanceador global que enrutaba por path: /api/* y /admin/* hacia Strapi, el resto hacia el front en Next.js. Una orquesta entera para un blog que publica cuatro veces al año y del que, en la práctica, sólo exprimía tres funciones: crear categorías, escribir y alojar imágenes.
Ese stack tenía un coste fijo cada mes —la instancia de SQL, sobre todo— y otro coste menos visible que duele más: el mantenimiento. Upgrades de Strapi, secretos que rotar, esquemas que migrar. Trabajo recurrente para sostener una infraestructura al servicio de un puñado de artículos.
De ahí sale la idea que vertebra toda la migración: el CMS ideal para un blog personal es la herramienta donde ya escribes. No el más potente ni el más extensible, ni el que mejor escala en un futuro hipotético que nunca llega; el que ya tenemos abierto. En mi caso, Notion. Usarlo como CMS significa cero infraestructura de contenido, la mejor experiencia de edición que podríamos pedir, y un flujo de publicación que se reduce a cambiar un select de Draft a Published.
La idea no es original, y conviene dar crédito: el detonante fue el artículo de Jordan Eldredge. De él nos quedamos con dos advertencias que —aviso— acabarán marcando cada decisión del resto del artículo: la API de Notion tiene rate limits agresivos, y las imágenes que aloja vienen con fecha de caducidad. Volveremos a las dos.
Con la idea clara, quedaba elegir arquitectura, y había bifurcación. La opción más resiliente era un sync job: un proceso que vuelca Notion a JSON o markdown en un bucket del que lee el front, dejando a Notion fuera del camino crítico… a cambio de sumar piezas (el job, el almacén intermedio, algo que dispare la sincronización). Elegí la contraria, más directa: que el front llame a la API de Notion en runtime y se apoye en las dos capas de caché que Next.js ya trae de serie. Sin job, sin almacén intermedio, sin webhooks. Con la arquitectura decidida, tocaba lo primero de verdad: enseñarle a Notion a comportarse como el CMS de un blog.
Modelar el blog en Notion sin que el front se entere
En Notion el contenido vive en una database, Articles, con las propiedades mínimas que un blog necesita: Title, Slug, Introduction, Categories, PublishedAt y un Status de Draft/Published. El cuerpo no es una propiedad: son los bloques nativos de la página, los mismos encabezados, párrafos, listas y bloques de código de cualquier nota. Escribimos el artículo como escribiríamos cualquier cosa en Notion.

Hay dos decisiones con algo de miga. La primera, un slug propio en vez de derivarlo del título, para conservar las URLs antiguas tal cual —tildes incluidas— y no tocar el SEO. La segunda, un PublishedAt manual, porque el created_time de Notion no se puede sobrescribir y las fechas originales de los artículos había que respetarlas.
Y aquí viene lo interesante, que es puro puertos y adaptadores. El front ya tenía su modelo de dominio —el que veníamos arrastrando de Strapi: Article, ContentBlock y compañía— y las funciones que lo alimentan (fetchLastArticles(), fetchArticle(slug)) hacen de puerto. En lugar de reescribir el front para que hablara el idioma de Notion, construimos un adaptador que traduce la respuesta de Notion a ese modelo que ya existía. Es una capa anticorrupción en sentido literal: Notion no entra en el dominio, se convierte a él en la frontera. Las firmas públicas no cambiaron, así que las páginas y los componentes apenas se tocaron.
La traducción tiene una ironía que merece contarse: Notion guarda rich text estructurado y nosotros lo reconvertimos a markdown para renderizarlo… con el mismo react-markdown de siempre. Cambiamos el origen de los datos de raíz y el renderer ni se enteró. Lo que nos lleva al primer problema serio de tener a Notion al otro lado del puerto: su API no se puede llamar a la ligera.
Rate limits y caché: no llamar a Notion en cada visita
Aquí aparece el primero de los dos avisos de Eldredge. La API de Notion tolera unas tres peticiones por segundo, y a poco que la mires entiendes que llamarla en cada visita a una página es pegarse un tiro: un artículo en portada un día movido se lleva la API por delante. La pregunta, entonces, no es cómo llamamos a Notion rápido, sino cómo evitamos llamarla casi siempre.
La respuesta son dos cachés de Next.js trabajando en cascada, y conviene entenderlas como dos filtros en serie: una petición solo llega a Notion si las atraviesa las dos.
La primera es la Full Route Cache (ISR), que cachea el HTML entero de la página. Durante el next build, generateStaticParams consulta Notion una vez, saca los slugs y pre-renderiza cada artículo a HTML estático que viaja dentro de la propia imagen Docker. A partir de ahí, mientras la página esté fresca (le damos una hora), Cloud Run sirve ese HTML sin ejecutar React ni rozar Notion. Y cuando caduca, lo bonito: Next no hace esperar a nadie. Sirve el HTML viejo al instante y regenera en segundo plano, de modo que la siguiente visita ya recibe la versión nueva. Es stale-while-revalidate: nadie paga jamás la latencia de Notion en su request. ¿Y un artículo publicado después del build, sin HTML previo? Esa primera visita se renderiza bajo demanda y queda cacheada como el resto, así que aparece sin necesidad de reconstruir nada.
La segunda capa es la Data Cache, que cachea las respuestas concretas de Notion. Cada fetch que hacemos lleva un next: { revalidate: 3600, tags: ["articles"] } y, con eso, Next guarda en disco lo que devuelve Notion. Hay un detalle que casi nadie espera: las queries de Notion son POST (van al endpoint de data sources de la API 2025-09-03), y Next cachea también los POST lanzados desde Server Components, usando como clave la URL más el body. La query del listado y la de cada artículo son entradas distintas porque sus bodies difieren, y ninguna vuelve a tocar Notion mientras siga fresca.
Puestas en cascada, las dos capas dibujan un recorrido muy fácil de seguir si lo pensamos como una sucesión de preguntas. Llega una petición: ¿hay HTML cacheado y fresco? Si lo hay, se devuelve y Notion no se entera —cero llamadas—. Si el HTML está caducado, el usuario recibe igualmente la versión vieja al instante y, por detrás, arranca la regeneración. Esa regeneración hace su propia pregunta a la segunda capa: ¿está fresca la Data Cache? Si lo está, el HTML nuevo se construye sin llamar a Notion tampoco; y solo si también ha caducado se hace la petición real, una o tres llamadas como mucho. Dicho de otro modo: para que el tráfico llegue a Notion tienen que fallar las dos cachés a la vez, en la misma página, en el mismo momento.
Petición
│
▼
¿HTML cacheado y fresco?
│
├── sí ──────────────► responde con HTML (0 llamadas a Notion)
│
└── no (stale)
│
├──► sirve HTML viejo YA (el usuario nunca espera)
│
└──► regenera en background
│
▼
¿Data Cache fresca?
│
├── sí ──────────► HTML nuevo (0 llamadas a Notion)
│
└── no ──────────► fetch a Notion (1-3 llamadas) ──► HTML nuevo
Hay una rama de este diagrama que conviene mirar de cerca, porque es la que más despista. ¿Qué pasa cuando el HTML ha caducado pero la Data Cache sigue fresca? Que la regeneración reconstruye la página con los datos que ya tenía guardados, así que el HTML "nuevo" sale idéntico al viejo: hemos pagado un render para servir exactamente lo mismo. Barato, pero inútil. Es el precio que pagamos por nuestra obsesión de fondo: minimizar al máximo las peticiones a Notion, aunque a veces eso signifique regenerar de más. Y todavía hay un escalón más sutil, que es el que de verdad determina cuánto tarda en verse un cambio: cuando caducan las dos a la vez, la Data Cache tampoco bloquea esperando a Notion —también es stale-while-revalidate—, le entrega el dato viejo a esa regeneración y refresca por detrás. O sea que la primera regeneración tras la expiración sigue saliendo con datos antiguos, y el dato fresco de Notion no aparece hasta la siguiente. La consecuencia práctica es que propagar un cambio no cuesta una ventana, sino dos: una para que la Data Cache traiga lo nuevo, otra para que una regeneración lo incorpore al HTML.
Esto explica una decisión que parece menor y no lo es: descartamos el SDK oficial. @notionhq/client hace sus propias peticiones HTTP por dentro, sin pasar por el fetch de Next, así que se salta la Data Cache entera y cada regeneración acabaría golpeando la API. La alternativa es un wrapper de apenas treinta líneas sobre fetch plano:
async function notionFetch(endpoint: string, body: unknown) {
const res = await fetch(`https://api.notion.com/v1/${endpoint}`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NOTION_TOKEN}`,
"Notion-Version": "2025-09-03",
"Content-Type": "application/json",
},
body: JSON.stringify(body),
next: { revalidate: 3600, tags: ["articles"] }, // ← lo que el SDK no deja pasar
})
return res.json()
}
Esa última línea, la opción next, es justo lo que el SDK no nos deja inyectar y la razón de prescindir de él.
Hagamos la cuenta, que es lo tranquilizador. Tres peticiones por segundo son unas 10.800 a la hora. En el peor de los casos imaginable —todas las páginas regenerándose a la vez con toda la Data Cache caducada— hablamos de diez o quince llamadas por hora. Estamos tres órdenes de magnitud por debajo del límite, y lo mejor es que el dato no depende del tráfico: las visitas pegan contra la Route Cache, nunca contra Notion. El blog podría hacerse viral y la API ni se enteraría.
Queda un cabo: si todo revalida cada hora, ¿cómo publicamos al instante? Para eso hay un endpoint, POST /api/revalidate?secret=..., que ejecuta revalidateTag("articles", "max") —en Next 16 el segundo argumento es obligatorio— e invalida de un tijeretazo todas las entradas con ese tag, atravesando las dos capas a la vez. El flujo de publicar queda en: cambiar Status a Published y un curl al endpoint.
Conviene ser honestos con el reverso de todo esto, porque es el trade-off que mencioné al elegir esta arquitectura: ambas capas son stale-while-revalidate, así que un cambio en Notion puede tardar hasta un par de ventanas en propagarse del todo. No es un bug, es consistencia eventual con un desfase acotado: a cambio de que ningún lector espere nunca a Notion, aceptamos que el contenido vaya como mucho un par de horas por detrás. Para un blog que cambia unas pocas veces al mes, es el cambio correcto; y cuando no queremos esperar, el curl al revalidate se salta las ventanas por completo. Esas ventanas no son el mecanismo de publicación, son la red de seguridad para cuando se nos olvida ejecutarlo.
Con las peticiones bajo control, queda el segundo aviso de Eldredge, que resultó bastante más escurridizo: las imágenes de Notion caducan.
Las imágenes que caducan
Y llegamos al segundo aviso de Eldredge, el que de verdad puede arruinarte el invento. Notion no aloja las imágenes en un sitio cualquiera: las guarda en S3 y te las sirve mediante URLs firmadas que caducan en una hora. La firma es la que autoriza la descarga, y pasados esos 3600 segundos, muere. ¿Ves el problema? Nosotros cacheamos el HTML hasta una hora —y con suerte más—, así que si incrustamos esa URL firmada directamente en la página, tenemos garantizado que tarde o temprano un lector se encontrará con imágenes rotas. Es, sin exagerar, el problema de usar Notion como CMS.
La salida pasa por no incrustar nunca la URL volátil. En su lugar montamos un proxy de rutas estables dentro del propio Next: en vez de escribir en el HTML la firma de S3, el transformador escribe rutas internas como /img/cover/{pageId} o /img/block/{blockId}. Y aquí está el truco: los identificadores de página y de bloque de Notion no cambian jamás, así que esas rutas son eternas aunque la imagen que hay detrás rote su firma cada hora.
¿Quién resuelve la firma fresca, entonces? El propio route handler, en el momento de servir la imagen. Esta fue su primera versión:
import { getBlock, getPage } from "@/api/notion"
const NOTION_REVALIDATE = { revalidate: 1800 } // 30 min, holgado dentro de la hora de firma
export async function GET(
_request: Request,
{ params }: { params: Promise<{ type: string; id: string }> },
) {
const { type, id } = await params
let url: string | undefined
if (type === "cover") {
const page = await getPage(id, NOTION_REVALIDATE)
url = page.cover?.file?.url
} else if (type === "block") {
const block = await getBlock(id, NOTION_REVALIDATE)
url = block.image?.file?.url
}
if (!url) return new Response("Not found", { status: 404 })
// Redirigimos a la URL firmada: los bytes van de S3 al navegador
return new Response(null, {
status: 302,
headers: { Location: url, "Cache-Control": "public, max-age=600" },
})
}
Lo interesante es la coreografía de caducidades, porque hay que cuadrarla con cuidado para no servir nunca una URL muerta. La firma de Notion dura 3600 segundos. Nuestra petición a Notion para obtener esa firma se cachea 1800 segundos (media hora reutilizando la misma sin volver a preguntar), y el navegador retiene la redirección otros 600. Hagamos el peor caso: una firma pedida hace 1800 segundos que un cliente retiene 600 más son 2400 segundos… cómodamente por debajo de los 3600 de vida. Nunca se entrega una firma caducada, y de paso Notion solo recibe un par de peticiones por imagen y hora, en perfecta sintonía con la obsesión de la sección anterior.
Problema resuelto, imágenes que ya no se rompen. Salvo que, al desplegar, saltó algo que no esperábamos: las imágenes cargaban visiblemente más lentas que con Strapi.
…y las que iban lentas
Recapitulemos el cliffhanger: las imágenes ya no se rompían, pero cargaban con una lentitud que con Strapi no teníamos. La primera hipótesis fue la cómoda, la que uno quiere creer: "Strapi comprimía las imágenes y Notion sirve el original". Suena razonable… y es falsa. El front antiguo también servía el original desde GCS —Strapi generaba miniaturas que nuestro código nunca llegó a usar—, así que eran los mismos bytes antes y después. Primera moraleja, y la más importante de toda la migración: mide antes de aceptar la explicación que te conviene.
Al medir de verdad aparecieron tres culpables, ninguno el sospechoso inicial. El primero, geografía: GCS vivía en europe-west1, servido desde el edge de Google con un time to first byte de una décima de segundo. El S3 de Notion está en us-west-2, Oregón. Desde España medimos unos 436 KB/s, y con eso una portada de 2,1 MB tardaba 4,7 segundos en cruzar el Atlántico. El segundo, la caché del navegador rota por diseño: como la URL firmada cambia en cada rotación, el navegador no reconoce la imagen entre visitas y se vuelve a tragar los 2,1 MB una y otra vez. Con GCS, donde la URL era estable, esto no pasaba. El tercero no era un culpable sino una víctima: el proxy respondía su redirección en 0,22 segundos, así que el problema no estaba en él.
La solución mata los tres pájaros de un tiro, y es una pieza que Next.js ya trae: la optimización de imágenes de next/image. En lugar de un <img> plano, el optimizador descarga la imagen una sola vez del lado del servidor —a través de nuestro proxy—, la redimensiona al ancho real de render, la recodifica a WebP o AVIF y guarda el resultado en el disco del Cloud Run europeo. A partir de ahí pasan dos cosas buenas a la vez: la URL que ve el navegador (/_next/image?url=/img/cover/...&w=640) es estable, así que su caché revive; y ese S3 lento de Oregón, que antes intervenía en cada visita, pasa a recibir una petición por imagen y tamaño al día. Los números hablan solos: una portada de 2,1 MB en PNG se convierte en un WebP de 55 KB a 640px de ancho —un 97% menos—; la primera petición tarda 2,2 segundos (la única descarga desde Oregón) y las siguientes, 2 milisegundos desde la caché del optimizador.
Pero meter next/image tuvo una consecuencia que no veíamos venir, y es la que cierra el círculo del proxy. Al activar el optimizador, las imágenes dejaron de cargar con un error desconcertante: "The requested resource isn't a valid image… received null". El motivo: el optimizador de Next no sigue redirecciones en sus srcs internos, y nuestro proxy de la sección anterior respondía justamente con un 302 a la URL firmada. La elegancia del 302 —dejar que los bytes fueran de S3 al navegador sin pasar por nuestro servidor— se había vuelto un obstáculo. Así que el handler pasó a streamear los bytes él mismo:
import { getBlock, getPage } from "@/api/notion"
const UUID_PATTERN = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i
// Notion signs its file URLs with ~1h expiry, so they can never be embedded in
// cached HTML. This route resolves a fresh signed URL on demand and streams the
// bytes (the next/image optimizer does not follow redirects on internal srcs).
// The Notion lookup revalidates every 30 min, well within the signature TTL,
// and the optimizer caches the optimized output for minimumCacheTTL (24h), so
// upstream fetches are rare.
const NOTION_REVALIDATE = { revalidate: 1800 }
export async function GET(_request: Request, { params }: { params: Promise<{ type: string; id: string }> }) {
const { type, id } = await params
if (!UUID_PATTERN.test(id)) {
return new Response("Not found", { status: 404 })
}
let url: string | undefined
try {
if (type === "cover") {
const page = await getPage(id, NOTION_REVALIDATE)
url = page.cover?.file?.url ?? page.cover?.external?.url
} else if (type === "block") {
const block = await getBlock(id, NOTION_REVALIDATE)
url = block.image?.file?.url ?? block.image?.external?.url
}
} catch {
return new Response("Not found", { status: 404 })
}
if (!url) {
return new Response("Not found", { status: 404 })
}
const upstream = await fetch(url, { cache: "no-store" })
if (!upstream.ok || !upstream.body) {
return new Response("Upstream error", { status: 502 })
}
return new Response(upstream.body, {
status: 200,
headers: {
"Content-Type": upstream.headers.get("content-type") ?? "image/jpeg",
"Cache-Control": "public, max-age=600",
},
})
}
Y aquí está la ironía que cierra la historia: el argumento original a favor del 302 era ahorrarle trabajo a nuestro servidor. Pero con el optimizador delante, al proxy ya solo le llega una petición por imagen y tamaño cada 24 horas, así que streamear los bytes salió gratis. El razonamiento se invirtió solo. Segunda moraleja: si usas Notion como CMS con imágenes, next/image no es una mejora opcional; es la pieza que convierte su hosting —lento, lejano y con URLs que rotan— en algo competitivo.
Conclusiones
Hagamos las cuentas del antes y el después, que es donde se ve si el viaje mereció la pena. Antes: dos servicios en Cloud Run, una instancia de Cloud SQL, un bucket, seis secretos y un balanceador enrutando por path para sostener un Strapi que había que mantener. Después: un único Cloud Run, dos secretos, un balanceador trivial y un "CMS" que no es más que una página de Notion. Publicar un artículo se ha quedado en escribirlo en Notion, cambiar Status a Published y, si no quiero esperar, un curl. Desaparece el mayor coste fijo —la base de datos— y, con él, casi todo el mantenimiento.
Queda una parte que este artículo no ha contado: cómo se movieron los artículos de verdad, con sus categorías, sus fechas y sus imágenes, y cómo se apagó Strapi sin que ningún lector notara nada —el script de migración, el cutover por fases, los rollbacks pensados para no romper producción ni un segundo—. Es una historia con suficiente miga propia como para merecer su propio artículo, así que la dejo para otro día.
Volviendo a la pregunta con la que abríamos: no sé si hace dos años acerté con Strapi, pero el cambio de hoy tengo claro que sí. Y si algo me llevo es que la mejor arquitectura no es la que más escala sobre el papel, sino la que se ajusta a lo que de verdad necesitas hoy. A veces eso significa una orquesta de servicios; a veces, sencillamente, escribir donde ya escribías.
Enjoy!