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.
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ó
| Eliminado | Por qué |
|---|---|
react-markdown | Reemplazado por Shiki + MDX en tiempo de build |
rehype-highlight | Reemplazado por Shiki |
highlight.js | Reemplazado por Shiki |
marked | Ya no es necesario |
BlogRenderer.tsx | 288 KB de bundle de React — eliminado |
| Todas las queries públicas a Supabase | Movidas 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.