---
title: "Upgrading from Astro 3 to Astro 6, with Claude doing most of the work"
publishDate: 2026-06-11T00:00:00.000Z
excerpt: "My site ran Astro 3 for three years. When Anthropic released the new Fable model, I tested it on the scariest item on my backlog: a three-major-version upgrade. This is how it went, numbers included."
category: "software-development"
tags: ["astro", "migration", "performance", "ai", "claude-code"]
canonical: https://neciudan.dev/astro-upgrade-from-3-to-6
---

After three years on Astro 3.0.2, this site was overdue for an upgrade.

I knew the project was behind. But I was surprised to find that I am three whole versions behind, with 45 known vulnerabilities in the dependency tree, a deprecated config, and a Tailwind integration the Astro team had stopped maintaining.

Migrating was very complicated. This project is much more than a blog: it includes courses, workshops, automated emails, reminders, subscriptions, podcast summaries, ads, and more. 

Even moving to a single new version was daunting.

But when Anthropic released Fable 5, the first in the Claude 5 family, I finally had a chance to put it to the test on a major project.

So I pointed it at the upgrade, and three days later, the site runs Astro 6.4.5, builds 44% faster, ships 96–98% less JavaScript on the course pages, and has half as many high- and critical-vulnerability issues as before. 

Along the way, we only broke the course production once. 

Oppsie. I'll get to that. But first here is how it went, step by step.

## Why now

The honest motivation was realizing that I was preaching modularity in every workshop while my entire app was all over the place. 

I wanted to move to microfrontends.

Before I can get there, I need to turn this site into a shell application (a container that loads independent feature modules). 

The first real extraction is the security course: its content, pages, and components will move into their own module folder.

On Astro 3, that's impossible, because 'content collections' (groups of content files managed by Astro) must live in `src/content/`. The framework decides where your content lives.

Astro 5 introduced the Content Layer API, which replaces that restriction with `loader` functions you can point anywhere. 

So the upgrade became a must-do to achieve modularization.

For the first PR, I had a clear goal: upgrade three majors with zero change to URLs, layout, styling, or behavior. 

Nothing moves, and nothing gets redesigned as tests are added along the way. 

## How it started

The plan had a big, hard check I would use as a gate (Which Fable recommended adding).

Before touching anything, a script captured every HTML file the Astro 3 build produced into a sorted list — 290 routes. 

After the upgrade, the same command runs again, and the two lists get diffed: if we don't have identical file paths and route slugs, the CI/CD failed.

This was the main risk for the migration: that the routes changed and my users would see 404s, or, worse, my SEO would go down. 

## What breaks between Astro 3 and 6

If you're sitting on an Astro 3 site, wondering what all the breaking changes are between the 3 versions, and are scared to take a look (like I was), the list is quite small (Congrats, Astro team). Let's go through them:

**Legacy content collections are gone.** 

This is the big one. Astro 6 removed support for schema-only (validation rules) collections entirely—every collection now requires an explicit Content Layer loader (a custom file-reading function). 

To update, you have to point a file selector called a `glob` loader at the folder where your content already lives. A `glob` loader is a tool that automatically detects files in a specified folder based on a filename pattern.

```ts
import { glob } from 'astro/loaders';

const postCollection = defineCollection({
  loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/post' }),
  schema: z.object({ /* unchanged */ }),
});
```

Your files don't move, and your zod schemas don't change. 

However, the way you access each entry changes: `entry.slug` (the user-friendly page identifier) no longer exists. Instead, it’s now `entry.id`, which is based on the file path (excluding the extension), and `entry.render()` is replaced by a standalone `render(entry)` function imported from astro:content.

This site had 32 instances where the `.slug` property (which typically represents a URL-friendly identifier) was read and 39 places where the `getCollection` function was called across four different data collections. 

Each of these `.slug` property accesses needed to be replaced with `.id` accesses, ensuring that the returned value remains unchanged.

If `entry.id` for a nested lesson file resolves to anything other than what `entry.slug` used to be, `getStaticPaths` generates different URLs, and your search rankings evaporate. 

Thanks to our route diff checker, we caught this early. 

**`output: 'hybrid'` no longer exists.** 

Astro 5 changed the hybrid content mode to 'static' by combining it into the output. 

Now, pages become static (pre-rendered) by default, and you can mark individual routes for server-side rendering (SSR) with export const prerender = false. (SSR means content is generated at request time on the server.) 

This is mostly a one-line configuration change, followed by checking where prerender is used.

**Node 22 is now required.** 

Astro 6.4.x requires Node.js version `>=22.12.0`. 

This affects several configurations — for this site, it meant specifying the Node.js version in `netlify.toml` (Netlify deploys), `.nvmrc` (local development), and `engines.node` (Node version for package managers) so local environments, continuous integration (CI), and the deployment runtime all use the same Node.js version.

**Your dependencies jump majors with you.** 

`@astrojs/netlify` went from version 3 to 7 here. The `npx @astrojs/upgrade` tool automatically finds the set of compatible package versions for you, which mostly works—except for one issue.

Three packages in this project (`@astrojs/tailwind`, `@astrolib/seo`, `@astrolib/analytics`) cap their peer dependencies at Astro 5, and Netlify runs a strict `npm ci` that refuses to install them. 

The pragmatic fix was setting `legacy-peer-deps=true` (which tells npm to ignore peer dependency conflicts) in `.npmrc`. The real fix is replacing all three packages. We deferred this so the upgrade stayed purely infrastructural. (But we will fix it later; hopefully, I won't forget.)

That's it. 

We had zero `Astro.glob` uses, the image API didn't complain, and the icon integration survived untouched.

## The migration, in numbers

Once the build was successfully completed ("green" means no errors), we measured the impact. Both builds used identical content on the same machine, so the comparison isolates the migration itself.

| Metric | Astro 3 | Astro 6 | Change |
|---|---|---|---|
| Production build (wall clock) | 25 s | 14 s | **−44%** |
| Astro server-build phase | 23.4 s | 9.9 s | **−58%** |
| Client JS (total) | 849 KB | 746 KB | **−12%** |
| Known vulnerabilities | 45 | 26 | **−42%** |
| — high + critical | 20 | 10 | **−50%** |
| HTML pages generated | 290 | 290 | identical |

The biggest pleasant surprise was that my build time was cut in half. 

Nothing about this site changed — same content, same pages — and the Astro server build phase dropped from 23.4~ seconds to 12~ seconds (I tried it 10 times and took the average). 

Those gains come from improvements in the Astro compiler and the Vite tool, simply by upgrading.

But External CSS grew by 116 KB because Astro changed how it decides what to inline and what to externalize between versions 3 and 6. (Honestly, this was a lot, and I flagged it for future improvements.)

The net payload (JS + CSS) moved 1.2%, and the rendered pages are visually identical. 

The route diff came back empty: 290 routes in, 290 routes out with the same slugs and content. 

Then, after a quick LGTM review, I merged the PR and now the interesting part could start.

## Auditing three major features

Upgrading a software framework and then not using any of its new features for three years would be like buying an F1 car and keeping it in the garage.

What did Astro 4, 5, and 6 add that this site should use to improve it?

Fable went through the release notes feature by feature and produced a ranked table — what each feature does, the concrete opportunity in this codebase, the expected benefit, the effort, and the risk. 

Here is the trimmed-down version of it:

| Feature | What it does | Opportunity here | Expected benefit | Effort | Risk |
|---|---|---|---|---|---|
| **Responsive Images** | Native `srcset`/`sizes`, `priority` prop for LCP | Replace 320+ lines of manual srcset logic in our custom `Image.astro` | Smaller images, correct `sizes`, ~320 lines deleted | S | Low |
| **`<ClientRouter />`** (View Transitions) | SPA-style navigation with animations | Blog index → post and course module nav | Instant perceived navigation | S | Medium — inline scripts need `astro:page-load` guards |
| **Prefetch** | Hover/viewport prefetching for links | Blog cards + course module links | Near-zero latency on common paths | S | Low |
| **`astro:env`** | Typed env schema: client/server, public/secret | 15+ untyped `import.meta.env` reads across 6 files | Build-time validation; secrets can't ship to the client | M | Low |
| **`astro:actions`** | Type-safe server actions with Zod validation | The subscribe API route + the form-handling Netlify functions | One typed contract instead of fetch boilerplate | L | Medium — Stripe/external functions keep their own runtime |
| **Server Islands** (`server:defer`) | CDN-cached static shell + deferred server-rendered component | Course dashboard runs auth 100% client-side, shipping 166 KB of Supabase to every visitor | Auth moves server-side; Supabase ships only when needed | L | High — needs server-readable (cookie) sessions |
| **Fonts API** | Fonts declared in config; auto preload + optimized fallbacks | Replace `@fontsource-variable/inter`; font preload is currently absent | LCP/CLS win on every page | S | Low |
| **SVG Components** | Import `.svg` files as components | Little — `astro-icon` already covers icons | Cleaner markup at best | S | Low |
| **`<Picture>` + AVIF** | Multi-format `<picture>` output | Hero images (likely the LCP element) are WebP-only today | AVIF is 30–50% smaller than WebP | S | Low |
| **Route caching** | SSR response cache | Only two SSR routes exist; Netlify edge caching already covers them | Little | M | High — experimental |
| **Queued rendering / Rust compiler** | Faster builds | CI build times | Faster builds | S | High — experimental |
| **Live Content Collections** | Request-time content without rebuilds | None — all content here is file-based | None | — | — |
| **CSP** | Auto-hashed inline scripts + `Content-Security-Policy` header | Needs an audit of every inline script first | XSS hardening | M | Medium |
| **i18n Routing** | Built-in locale routing | None — the site is English-only | None | — | — |

Alongside it, two more audits that I do every 3 months: accessibility (WCAG 2.2 AA, beyond what automated tools catch) and SEO. (If you want to read more about this, I have a nice [Astro SEO article](https://neciudan.dev/astro-seo-checklist-2026) and an [Astro Performance article](https://neciudan.dev/how-i-cut-250gb-of-bandwidth-from-my-website))

Two things about that opportunities document stood out to me.

I loved the fact that it had a "not worth adopting" section with reasons. 
- Live content collections: I dont have a CMS, so there are no benefits here. 
- The experimental Rust compiler: wait for stable (plus my build time is already at 12 seconds). 
- i18n routing: the site is English-only. 

It declined more features than it recommended, which made the recommendations easier to take seriously.

Second, it flagged its own biggest recommendation as risky. 

Server Islands promised the largest win — the course pages shipped a 166 KB Supabase client to every anonymous visitor just to decide whether to show a login gate — but the audit marked it high-risk because it meant rebuilding the auth layer, and recommended a dedicated PR rather than bundling it with the easy wins.

So I(or rather Claude Fable) started with the quick, easy wins:

**The built-in Fonts API.** 

Astro 6 lets you declare fonts in the config and handles downloading, caching, preloading, and fallback generation.

This replaced the `@fontsource-variable/inter` npm package — and added `<link rel="preload">` for the font on all 289 pages, which had been entirely absent before. Free LCP and CLS improvement, plus a metric-adjusted system-font fallback while the webfont loads.

**Prefetch.** 

One config line enables hover and viewport prefetching on links. Blog cards and course navigation (133 pages) now start fetching the next page before you click.

**Lazy-loading the Supabase client off the dashboard entry script.** 

The course dashboard's entry bundle went from roughly 170 KB to 7.6 KB by not importing Supabase until it's needed. This one is not even an Astro feature; it was an old problem that the audit surfaced along the way.

**Bundling Prism instead of pulling it from a CDN.** 

Every lesson page loaded its syntax highlighting via four render-blocking third-party `<script>` tags. Now it's zero CDN requests.

The same PR carried the accessibility fixes (the worst one: a background video ignoring `prefers-reduced-motion`) and a dead-code sweep with [knip (one of my favorite libraries I found this year)](https://neciudan.dev/7-cool-javascript-libraries-you-might-want-to-use) that deleted 274 stale build artifacts, 15 unused components, and 5 unused dependencies.

## Typed environment variables (astro:env)

The next PR was small and was created because of a recent incident.

A few weeks before this migration, I took the course login down in production by using the wrong Supabase key in the wrong place — a secret key where a publishable key should have been. 

Nothing in the toolchain caught it, because every environment variable was read through `import.meta.env.*`, which is untyped and resolves to `undefined` when you typo a name.

Astro 5 shipped `astro:env` for exactly this. 

You declare a schema in the config, classifying each variable by context (`client` or `server`) and access (`public` or `secret`):

```ts
env: {
  schema: {
    PUBLIC_SUPABASE_URL: envField.string({ context: 'client', access: 'public' }),
    YOUTUBE_API_KEY: envField.string({ context: 'server', access: 'secret' }),
  },
},
```

A missing required variable now causes the build to fail instead of failing in production. 

A server secret can't be imported into client code at all — the import itself errors. The audit flagged that our YouTube API key was one careless import away from being shipped to browsers.

The PR also added a convention-guard test that fails if any Astro source file reads a custom variable through raw `import.meta.env`. 

This ensures that if I add more secrets in the future, I respect the convention of adding them to the schema first.

## Server Islands, and the login outage

With the foundations in place, we went after the biggest item on the audit: Server Islands.

The course dashboard worked the way most client-gated pages do: the server sends a shell, the browser downloads the Supabase client, the client checks `localStorage` for a session, and the page decides whether to render a login gate or your dashboard.

Every visitor pays for that, including the anonymous majority and every crawler — they download 166 KB of Supabase just to be told they're not logged in. 

And the page can't be CDN-cached because its content depends on a check that runs only in the browser.

Server Islands are a cool feature that fixes this. 

The page becomes a static, cacheable shell, and the personalized part — the dashboard — is a component marked `server:defer` that renders in a separate server request, with a skeleton in its place while it loads.

For the server to render your dashboard, though, it has to know who you are. 

A session in `localStorage` is invisible to the server, so the foundation work was moving Supabase auth to cookie-based sessions with `@supabase/ssr`, plus an Astro middleware that resolves the user from cookies on each request.

Here are the measured results on the dashboard if you are a non-authenticated user, prod (v3), versus the preview (v6 with Server Islands):

| Metric | Before | After | Δ |
|---|---|---|---|
| First-party JS downloaded | 178 KB (7 files) | 7 KB (5 files) | **−96%** |
| Supabase client chunk | loaded (166 KB) | not loaded | eliminated |
| Auth gate | client check after load (flash) | server-rendered | — |

The PR build was green, 23 e2e tests passed, and 330 unit tests passed. I also reviewed the code, and it looked good. We merged it.

And the course login broke in production.

Anyone clicking a magic link from their email got "PKCE code verifier not found in storage." 

The auth callback page still ran the code exchange in the browser, but the PKCE verifier — the proof that the client finishing the login is the one that started it — now lived in a cookie context the client-side exchange couldn't read. 

The login was down. We reverted and went back to the drawing board.

Every automated gate had passed. The tests can simulate a login, but none of them can open my inbox and click an actual magic link against the real Netlify runtime. I also didn't have an e2e test for this URL. 

The redo PR fixed the root cause — the callback became a server route that performs `exchangeCodeForSession` with the cookie-backed server client.

I signed in via the branch preview URL created by Netlify, clicked the link in my inbox (Should have done this in the previous PR as well), and landed on the authenticated page. Lesson learned: check more myself; this was the one thing Fable missed in the codebase for this project.

Then we merged.

## Phase B: a server island for every lesson

With cookie auth proven in production, Phase B applied the same pattern to the rest of the course: all 44 lesson pages and the module pages became server islands.

Each page is now a static shell the CDN can cache, with the content inside a `server:defer` island. 

The island reads `Astro.locals.user` from the middleware and renders one of two things: a logged-in student gets the full lesson with their progress and feedback widgets, and an anonymous visitor gets a gate with the lesson title and a sign-up CTA.

The lesson body never leaves the server for anonymous requests, so the gate is enforced server-side — you can't bypass it by disabling JavaScript or opening View Source.

The progress and feedback features moved too. "Mark as complete" and the star rating used to call Supabase straight from the browser; they now post to cookie-authenticated server endpoints (`/api/course/progress` and `/api/course/feedback`) that validate the session user before writing anything. 

With that, the Supabase browser client is no longer in the course and will never be downloaded.

The results, measured logged out on a lesson page, prod(v3) versus the preview(v6):

| Metric | Before | After | Δ |
|---|---|---|---|
| First-party JS | 260 KB (12 files) | 4 KB (3 files) | **−98%** |
| Supabase client chunk | loaded (166 KB) | not loaded | eliminated |
| Lesson body in anonymous HTML | present (hidden by JS) | absent | — |

## Thirteen endpoints become one action

The last PR was a cleanup I'd been avoiding.

Every subscribe form on this site — newsletter, podcast, course waitlist, conference raffles, fourteen forms in all — is posted to its own hand-rolled Netlify function, which is forwarded to a Google Apps Script that writes to a spreadsheet. (Yes, I use Google Sheets as a DB, it's free!)

That added up to eleven near-identical functions plus an API route, with no input validation, and a copy-pasted fetch boilerplate in every form. 

Worse, the Apps Script URL lived in a `PUBLIC_`-prefixed environment variable, where nothing stopped it from shipping to every browser.

`astro:actions`, stable since Astro 5, allowed me to move away from Netlify functions(which cost money). 

You define a server function with a zod input schema and call it from the client like a typed local function:

```ts
export const server = {
  subscribe: defineAction({
    input: z.object({
      audience: z.enum(['newsletter', 'podcast', 'master-security' /* … */]),
      email: z.string().email(),
    }),
    handler: async (input) => {
      // one place that talks to the Apps Script, server-side
    },
  }),
};
```

Thirteen server endpoints became one action. The Apps Script URL moved into `astro:env`'s server schema, so it no longer exists in any client bundle — an e2e test now greps the built output to keep it that way.

A malformed email gets rejected by Zod before any network call. Net effect: 377 fewer lines, and one place to change when the subscribe logic changes.

The win here was security and deleting twelve copies of the same code.

## Working with Fable

I said at the start that this migration was partly an excuse to test the new model, so here is the verdict.

Workflow was pretty much the same. I use the superpowers skill to do spec-driven development. The difference between Opus and Fable is, for sure, the depth it goes into a subject, how much more it understands, and the speed. 

It also runs additional internal loops to verify its assumptions.

The biggest improvement I saw was in the way it limits itself: it creates better tests, measures performance impact, and checks for security vulnerabilities (without me asking for it). 

And it deferred well, which I did not expect. Declining `<ClientRouter />` because of 38 inline scripts, leaving the Stripe functions alone during the actions migration. 

It understood risk better.

## Was it worth it

The scoreboard at the end:

- Build time: 25s → 12s after all seven PRs.
- Anonymous course dashboard: 178 KB of first-party JS → 7 KB.
- Anonymous lesson page: 260 KB → 4 KB, with the content actually protected now.
- Known vulnerabilities: 45 → 26, high and critical halved.
- Server endpoints for forms: 13 → 1, all typed.
- Font preloads: 0 pages → 289 pages.
- Secrets protected
- 1 Major Outage for 10 minutes

Took around 8-10 hours of my half attention. While trusting Claude Fable with everything.

My tip is to capture your route inventory before you start and diff it after, to make sure you are not breaking anything.

Don't stop at the version bump, though. The new features are amazing, and implementing them took six more PRs. 

Really happy with Astro and the Astro team for the improvements in the last year! 

It truly is the best framework on the market! (Sorry, TanStack Start)

## References

- [Astro Content Layer API](https://docs.astro.build/en/guides/content-collections/) — the loader-based collections that replaced legacy collections
- [Upgrade to Astro v5](https://docs.astro.build/en/guides/upgrade-to/v5/) and [Upgrade to Astro v6](https://docs.astro.build/en/guides/upgrade-to/v6/) — the official migration guides
- [Server Islands](https://docs.astro.build/en/guides/server-islands/) — `server:defer` and the static-shell pattern
- [astro:env](https://docs.astro.build/en/guides/environment-variables/) — typed, validated environment variables
- [Astro Actions](https://docs.astro.build/en/guides/actions/) — type-safe server functions with zod validation
- [Astro Fonts API](https://docs.astro.build/en/guides/fonts/) — the built-in font loading shipped in Astro 6
- [@supabase/ssr](https://supabase.com/docs/guides/auth/server-side/creating-a-client) — cookie-based Supabase auth for server-side rendering
- [knip](https://knip.dev/) — the dead-code finder used in the cleanup pass
