Cómo Pregón sincroniza tu catálogo en cualquier dispositivo: arquitectura offline-first
El detalle técnico de cómo Pregón funciona con Cloudflare Pages al borde, Astro 6 + Svelte 5 islands, Supabase para datos y un service worker que aguanta cortes de internet. Para devs y comerciantes técnicos.
- Astro
- Svelte 5
- Cloudflare Pages
- Supabase
Pregón nació de una restricción muy concreta: el cliente final puede estar en una conexión inestable. En muchos mercados eso significa 2G la mitad del tiempo, datos racionados, cortes de varias horas. En otros lugares es el dueño del puesto del mercado con un Android entrante de gama baja, o el mensajero entregando con red intermitente.
Esto cambió todas las decisiones técnicas que tomamos. Aquí está la arquitectura completa, sin humo.
El stack en una línea
Astro 6 (server output) + Svelte 5 islands + Cloudflare Pages + Workbox + Supabase Postgres + Drizzle ORM.
Cada pieza está ahí por una razón específica, no por tendencia.
Por qué Astro server-output y no SSG
La opción obvia para una landing es SSG (Static Site Generation): generas HTML al build, lo subes a CDN, fin. Es rápido y barato. Lo descartamos.
El catálogo de un negocio cambia muchas veces al día — agregar producto, cambiar precio, marcar agotado. Con SSG necesitas un build por cada cambio, y multiplicado por miles de tiendas eso se vuelve inviable.
Lo que hacemos:
- Landing pública (
/,/recetas,/tech,/marketplace) → SSG conprerender = trueen cada página. Cero latencia, cero costo por petición. - Tiendas individuales (
/[slug]) → SSR en el edge de Cloudflare Workers. Cada visita ejecuta el render en el datacenter más cercano al usuario. El catálogo se consulta a la API de PadminC y se cachea agresivamente. - Carrito, checkout, account → SSR con headers anti-caché. Datos personales nunca tocan caché compartida.
// src/pages/[slug]/index.astro
export const prerender = false; // SSR para tiendas
// src/pages/recetas/[...slug].astro
export const prerender = true; // SSG para blog
Astro 6 te deja mezclar prerender por página dentro del mismo output: 'server'. Eso es lo que hace posible este split.
Svelte 5 islands: el truco para no enviar JS innecesario
El 80% de la landing es HTML estático. No necesita JavaScript en el cliente.
El 20% restante son cosas como el header scroll-aware, el formulario de early access, los steppers con estado. Esas son islas de Svelte 5 con hidratación selectiva:
<!-- Sin JS -->
<Hero />
<!-- JS solo cuando el componente es visible -->
<BeforeAfter client:visible />
<!-- JS al cargar (header se necesita arriba inmediatamente) -->
<Header client:load />
El resultado en producción: la página inicial envía ~24KB de JS (gzip) — la mitad de eso es la hidratación de Svelte misma. Sin Svelte sería 0 KB; con React+Next sería 80+ KB. Svelte 5 con runes es el equilibrio.
Los runes ($state, $derived, $effect, $props) compilan a updates muy puntuales del DOM. Cero virtual DOM, cero diff. Esto importa mucho en Android de gama baja.
Service Worker: el seguro contra cortes de red
Aquí entra Workbox vía @vite-pwa/astro. Tres estrategias de caché distintas según el tipo de recurso:
// astro.config.mjs (extracto)
runtimeCaching: [
// HTML: NetworkFirst con fallback a caché de 5 min.
// Si la red se cae, sirve la última versión vista.
{
urlPattern: ({ request }) => request.mode === 'navigate',
handler: 'NetworkFirst',
options: {
cacheName: 'vifly-pages-v1',
networkTimeoutSeconds: 5,
expiration: { maxAgeSeconds: 300 },
},
},
// Imágenes: CacheFirst, 7 días.
// Los productos no cambian de foto cada hora.
{
urlPattern: ({ request }) => request.destination === 'image',
handler: 'CacheFirst',
options: { cacheName: 'vifly-images-v1', expiration: { maxAgeSeconds: 604800 } },
},
// Fuentes: CacheFirst, 1 año. Variable Inter self-hosted.
// Cero llamadas a Google Fonts.
{
urlPattern: ({ request }) => request.destination === 'font',
handler: 'CacheFirst',
options: { cacheName: 'vifly-fonts-v1', expiration: { maxAgeSeconds: 31536000 } },
},
]
Con esto, si la red se cae mientras el cliente está navegando tu catálogo, sigue viendo lo último que cargó. Si intenta navegar a una página nueva, aparece la versión cacheada con un banner discreto de “estás viendo la última versión guardada”.
Adaptive bandwidth: el truco que la mayoría no implementa
El navegador moderno expone navigator.connection con info sobre la conexión real del usuario. Lo leemos en el BaseLayout y le ponemos un dataset al <html>:
const connection = navigator.connection;
const saveData = Boolean(connection?.saveData);
const effectiveType = String(connection?.effectiveType ?? '').toLowerCase();
const isSlowType = effectiveType === 'slow-2g' || effectiveType === '2g';
const isLowBandwidth = saveData || isSlowType || (connection?.downlink ?? 0) < 0.8;
document.documentElement.dataset.connection = isLowBandwidth ? 'low' : 'normal';
Luego en CSS:
html[data-connection='low'] [class*='backdrop-blur'] {
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
}
html[data-connection='low'] .skeleton {
animation: none !important;
background: var(--color-muted);
}
Resultado: en 2G desactivamos blurs, animaciones de shimmer, transiciones pesadas. La página sigue siendo perfectamente funcional y se ve sobria, no “bonita-pero-pesada”.
Supabase como base de datos + Drizzle como capa de tipo seguro
Para PadminC (el admin de cada negocio) usamos:
- Supabase Postgres — multi-tenant con Row Level Security. Cada tienda solo ve sus datos.
- Drizzle ORM — queries con tipos completos desde el schema. Cero strings SQL en runtime, cero queries equivocadas en producción.
- API pública
/api/public/*— el Storefront (este sitio) consume esto. Tiene rate limit y cache headers fuertes.
El Storefront nunca habla directo a Supabase. Si lo hiciera, expondríamos la clave anónima y el RLS no nos protegería de DDOS contra el catálogo público. Toda lectura pública pasa por la API de PadminC que cachea en Workers KV con TTL corto.
Lo que no usamos (y por qué)
- Next.js / Nuxt: bundle inicial demasiado pesado para nuestro caso. Astro envía HTML primero, JS solo donde lo necesitas.
- Tailwind v3: usamos v4. El motor JIT en v3 era lento en proyectos grandes; v4 con el plugin Vite hace HMR de CSS en ~50ms.
- shadcn/ui: portamos nuestros propios componentes en Svelte. shadcn está pensado para React y la mecánica de “copy-paste components” no se traduce limpiamente a Svelte 5 con runes.
- GraphQL: REST con tipos compartidos vía Drizzle Zod schemas. GraphQL añade complejidad sin payoff claro en este tamaño de proyecto.
- GSAP de entrada en toda la página: lo agregamos pero no lo cargamos por defecto. La narrativa caos→orden del hero se hace 100% con CSS + Svelte runes. GSAP queda como herramienta para casos puntuales.
Performance numbers
Sobre Cloudflare Pages, viewport mobile (375×812), conexión simulada 4G:
| Métrica | Landing | Tienda individual |
|---|---|---|
| LCP | ~1.1s | ~1.4s |
| INP | <100ms | <120ms |
| CLS | 0.00 | 0.01 |
| JS gzip | 24 KB | 32 KB |
| CSS gzip | 14 KB | 18 KB |
Con data-connection=low esos números bajan otro ~20% porque desactivamos blurs y animaciones.
Lo que viene
- Sync con POS local (Tauri + Svelte) — el dueño del negocio escanea productos en el mostrador, Pregón actualiza el catálogo público en segundos.
- Bot de Telegram para Red de Ventas — vendedores informales reciben pedidos por bot, sin tocar el admin.
- OG dinámica con Workers — por ahora servimos un SVG estático, vamos a generar PNG con Satori en el edge.
¿Te interesa algo en concreto? Si quieres ver código real de alguna parte (cómo armamos el RLS, cómo cacheamos en Workers KV, cómo testeamos islands), escribe a [email protected] o abre una conversación en nuestro WhatsApp. Las notas técnicas que veas aquí salen de las preguntas que nos hacen.