Volver al blog
astro performance content-collections ssg typescript arquitectura supabase

Rediseñando Mi Portafolio: De Supabase SSR a Static-First con Astro 6

Un desglose técnico de la migración de un portafolio multilingüe desde una arquitectura SSR respaldada por Supabase a un sitio completamente estático con Content Collections — eliminando todas las consultas de base de datos en tiempo de ejecución del front-end público.

· 8 min
Infraestructura de servidor

La arquitectura original del portafolio seguía un patrón común y equivocado: cada carga de página disparaba una consulta a Supabase para obtener contenido que cambia pocas veces al año. Proyectos, certificaciones, posts del blog — todo consultado en tiempo de ejecución desde una base de datos relacional, con costo para cada visitante.

Este post es un desglose técnico de la migración a una arquitectura completamente estática usando Astro 6 Content Collections.

El Problema con las Queries en Runtime para Contenido Estático

El contenido que no cambia entre deployments no debería consultarse en tiempo de request. Los costos son concretos:

  • Latencia: cada página añade un roundtrip en frío a Supabase
  • Límites de rate: el tier gratuito de Supabase tiene límites de conexión que un pico de tráfico alcanzará
  • Acoplamiento: la UI no puede renderizarse sin que la base de datos esté disponible

La solución es mover esa query al momento del build. Las Content Collections de Astro 6 con loader: glob() hacen que esto sea un patrón de primera clase.

La Migración: Fase por Fase

Blog → MDX

El blog fue la migración más simple. Supabase almacenaba el contenido de los posts en una tabla blog_translations unida a blog_posts. El script de migración exportó cada traducción a un archivo bajo src/content/blog/{lang}/{slug}.mdx.

La ruta [slug].astro cambió de una query a Supabase a:

export async function getStaticPaths() {
  const posts = await getCollection('blog', (e) => !e.data.draft);
  const enPosts = posts.filter((e) => e.id.startsWith('en/'));
  return enPosts.map((post) => ({
    params: { slug: post.id.replace(/^en\//, '').replace(/\.mdx?$/, '') },
    props: { post },
  }));
}

const { post } = Astro.props;
const { Content } = await render(post);

Resultado: HTML estático pre-renderizado en tiempo de build. Cero JS en runtime para contenido del blog. Shiki maneja el syntax highlighting del lado del servidor — ni un solo byte de highlight.js se envía al navegador.

El componente BlogRenderer.tsx — un wrapper de React alrededor de react-markdown — fue eliminado. Eso son 288 KB de bundle de JavaScript eliminados de cada página del blog.

Proyectos y Certificaciones → JSON Collections

Los proyectos y certificaciones siguieron el mismo patrón. El schema en content.config.ts:

const technologySchema = z.object({
  name: z.string(),
  category: z.enum(['frontend', 'backend', 'database', 'devops', 'mobile', 'design', 'other']),
});

const projects = defineCollection({
  loader: glob({ pattern: '**/[^_]*.json', base: './src/content/projects' }),
  schema: z.object({
    image: z.string().optional(),
    status: z.enum(['planning', 'in-progress', 'completed', 'archived', 'private']).default('completed'),
    featured: z.boolean().default(false),
    translations: z.object({
      en: z.object({ title: z.string(), description: z.string() }),
      es: z.object({ title: z.string(), description: z.string() }),
    }),
    technologies: z.array(technologySchema).default([]),
  }),
});

El campo technologies cambió de un string separado por comas a un array estructurado — habilitando filtrado tipado por categoría sin parsing en runtime.

FilterComponent: De Asíncrono a Síncrono

La UI de filtros en las páginas de proyectos y certificaciones anteriormente hacía queries a Supabase para poblar los dropdowns de tecnología y categoría. Después de la migración, todos los datos se pasan como props initialData desde el servidor, y el componente deriva sus opciones de filtrado desde ese array:

// Derivado síncronamente desde initialData — sin async, sin DB
const availableTechnologies = $derived(
  [...new Map(
    initialData.flatMap((item) =>
      Array.isArray(item.technologies) ? item.technologies : []
    ).map((t) => [t.name, t])
  ).values()].sort((a, b) => a.name.localeCompare(b.name))
);

El filtrado ahora es una operación pura en memoria. El componente se renderiza completamente en el servidor con el estado inicial correcto, y las interacciones del lado del cliente son instantáneas — sin roundtrip de red.

Lo Que Se Eliminó

EliminadoPor qué
react-markdownReemplazado por Shiki + MDX en tiempo de build
rehype-highlightReemplazado por Shiki
highlight.jsReemplazado por Shiki
markedYa no es necesario
BlogRenderer.tsx288 KB de bundle de React — eliminado
Todas las queries públicas a SupabaseMovidas al tiempo de build

Lo Que Permanece en Supabase

El panel de administración todavía usa Supabase para operaciones CRUD — crear, editar y eliminar proyectos y certificaciones. Esas rutas tienen export const prerender = false y llaman a Supabase en tiempo de request, lo cual es apropiado: las operaciones de admin son escrituras disparadas por el usuario, no lecturas públicas.

El modelo ahora es limpio: Supabase es un backend de escritura para el admin. El sitio público lee desde el sistema de archivos.

La Arquitectura en Una Frase

Build time obtiene todo, lo compila a HTML y JSON, y no envía ninguna conexión de base de datos a los usuarios públicos.