Back to blog
astro performance content-collections ssg typescript architecture supabase

Re-architecting My Portfolio: From Supabase SSR to Static-First with Astro 6

A technical breakdown of migrating a multi-language portfolio from a Supabase-backed SSR architecture to a fully static Content Collections site — eliminating all runtime database queries from the public front-end.

· 8 min
Server infrastructure

The original portfolio architecture followed a pattern that’s common and wrong: every page load triggered a Supabase query to fetch content that changes a few times per year at most. Projects, certifications, blog posts — all fetched at runtime from a relational database, paid for by every visitor.

This post is a technical breakdown of the migration to a fully static architecture using Astro 6 Content Collections.

The Problem with Runtime Queries for Static Content

Content that doesn’t change between deployments has no business being fetched at request time. The costs are concrete:

  • Latency: every page adds a cold-start Supabase roundtrip
  • Rate limits: Supabase’s free tier has connection limits that a traffic spike will hit
  • Coupling: the UI can’t render without the database being up

The fix is to move that query to build time. Astro 6 Content Collections with loader: glob() make this a first-class pattern.

The Migration: Phase by Phase

Blog → MDX

The blog was the simplest migration. Supabase stored post content in a blog_translations table joined to blog_posts. The migration script exported each translation to a file under src/content/blog/{lang}/{slug}.mdx.

The [slug].astro route changed from a Supabase query to:

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);

Result: static HTML pre-rendered at build time. Zero runtime JS for blog content. Shiki handles syntax highlighting server-side — not a single byte of highlight.js ships to the browser.

The BlogRenderer.tsx component — a React wrapper around react-markdown — was deleted. That’s 288 KB of JavaScript bundle eliminated from every blog page.

Projects and Certifications → JSON Collections

Projects and certifications followed the same pattern. The schema in 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([]),
  }),
});

The technologies field changed from a comma-separated string to a structured array — enabling typed category filtering without parsing at runtime.

FilterComponent: From Async to Synchronous

The filter UI on the projects and certifications pages previously made Supabase queries to populate the technology and category dropdowns. After the migration, all data is passed as initialData props from the server, and the component derives its filter options from that array:

// Derived synchronously from initialData — no async, no 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))
);

Filtering is now a pure in-memory operation. The component renders fully on the server with correct initial state, and client-side interactions are instant — no network roundtrip.

What Was Deleted

RemovedWhy
react-markdownReplaced by Shiki + MDX at build time
rehype-highlightReplaced by Shiki
highlight.jsReplaced by Shiki
markedNo longer needed
BlogRenderer.tsx288 KB React bundle — gone
All public Supabase queriesMoved to build time

What Stays in Supabase

The admin panel still uses Supabase for CRUD operations — creating, editing, and deleting projects and certifications. Those routes are export const prerender = false and call Supabase at request time, which is appropriate: admin operations are user-triggered writes, not public reads.

The model is now clean: Supabase is a write backend for the admin. The public site reads from the file system.

The Architecture in One Sentence

Build time fetches everything, compiles it to HTML and JSON, and ships zero database connections to public users.