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.
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
| Removed | Why |
|---|---|
react-markdown | Replaced by Shiki + MDX at build time |
rehype-highlight | Replaced by Shiki |
highlight.js | Replaced by Shiki |
marked | No longer needed |
BlogRenderer.tsx | 288 KB React bundle — gone |
| All public Supabase queries | Moved 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.