Skip to content
⚡ LIVE From Lizard to Wizard · Wednesday, August 5 · LIMITED SEATS Save my seat →

· software development · 17 min read

Upgrading from Astro 3 to Astro 6, with Claude doing most of the work

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.

Neciu Dan

Neciu Dan

Hi there, it's Dan, a technical co-founder of an ed-tech startup, host of Señors at Scale - a podcast for Senior Engineers, Organizer of ReactJS Barcelona meetup, international speaker and Staff Software Engineer, I'm here to share insights on combining technology and education to solve real problems.

I write about startup challenges, tech innovations, and the Frontend Development. Subscribe to join me on this journey of transforming education through technology. Want to discuss Tech, Frontend or Startup life? Let's connect.

Share:

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.

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.

MetricAstro 3Astro 6Change
Production build (wall clock)25 s14 s−44%
Astro server-build phase23.4 s9.9 s−58%
Client JS (total)849 KB746 KB−12%
Known vulnerabilities4526−42%
— high + critical2010−50%
HTML pages generated290290identical

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:

FeatureWhat it doesOpportunity hereExpected benefitEffortRisk
Responsive ImagesNative srcset/sizes, priority prop for LCPReplace 320+ lines of manual srcset logic in our custom Image.astroSmaller images, correct sizes, ~320 lines deletedSLow
<ClientRouter /> (View Transitions)SPA-style navigation with animationsBlog index → post and course module navInstant perceived navigationSMedium — inline scripts need astro:page-load guards
PrefetchHover/viewport prefetching for linksBlog cards + course module linksNear-zero latency on common pathsSLow
astro:envTyped env schema: client/server, public/secret15+ untyped import.meta.env reads across 6 filesBuild-time validation; secrets can’t ship to the clientMLow
astro:actionsType-safe server actions with Zod validationThe subscribe API route + the form-handling Netlify functionsOne typed contract instead of fetch boilerplateLMedium — Stripe/external functions keep their own runtime
Server Islands (server:defer)CDN-cached static shell + deferred server-rendered componentCourse dashboard runs auth 100% client-side, shipping 166 KB of Supabase to every visitorAuth moves server-side; Supabase ships only when neededLHigh — needs server-readable (cookie) sessions
Fonts APIFonts declared in config; auto preload + optimized fallbacksReplace @fontsource-variable/inter; font preload is currently absentLCP/CLS win on every pageSLow
SVG ComponentsImport .svg files as componentsLittle — astro-icon already covers iconsCleaner markup at bestSLow
<Picture> + AVIFMulti-format <picture> outputHero images (likely the LCP element) are WebP-only todayAVIF is 30–50% smaller than WebPSLow
Route cachingSSR response cacheOnly two SSR routes exist; Netlify edge caching already covers themLittleMHigh — experimental
Queued rendering / Rust compilerFaster buildsCI build timesFaster buildsSHigh — experimental
Live Content CollectionsRequest-time content without rebuildsNone — all content here is file-basedNone
CSPAuto-hashed inline scripts + Content-Security-Policy headerNeeds an audit of every inline script firstXSS hardeningMMedium
i18n RoutingBuilt-in locale routingNone — the site is English-onlyNone

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 and an Astro Performance article)

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

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

MetricBeforeAfterΔ
First-party JS downloaded178 KB (7 files)7 KB (5 files)−96%
Supabase client chunkloaded (166 KB)not loadedeliminated
Auth gateclient 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):

MetricBeforeAfterΔ
First-party JS260 KB (12 files)4 KB (3 files)−98%
Supabase client chunkloaded (166 KB)not loadedeliminated
Lesson body in anonymous HTMLpresent (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:

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

🏆 SOLD OUT IN SINGAPORE · ATHENS · LONDON

From Lizard to Wizard

4-hour remote system design intensive.
Chat apps, microfrontends, BFF, SDUI, event-driven, observability.

€299 4-HOUR INTENSIVE
Save your seat →

Spots are vanishing. Don't be the one who waited.

Neciu Dan

Discover more from The Neciu Dan Newsletter

A weekly column on Tech & Education, startup building and occasional hot takes.

Over 1,000 subscribers

🎙️ Latest Podcast Episodes

Dive deeper with conversations from senior engineers about scaling applications, teams, and careers.

React Native at Scale with Kadi Kraman
Episode 35
60 minutes

Señors @ Scale host Neciu Dan sits down with Kadi Kraman, software developer at Expo working on the tools that make React Native development as smooth as possible. Kadi's path started with C++ in a university maths degree, took her through Angular 1, scientific programming for pharmaceutical and defense companies, five and a half years at Formidable, and finally to Expo itself. From the limitations of early React Native to development builds, EAS workflows, fingerprint-based repacks, and the right way to think about over-the-air updates, this is the React Native conversation most web developers never get.

📖 Read Takeaways
Browser ML at Scale with Nico Martin
Episode 34
66 minutes

Señors @ Scale host Neciu Dan sits down with Nico Martin — open source ML engineer at Hugging Face working on Transformers.js, and Google Developer Expert in AI and web technology — to go deep on running machine learning models directly in the browser. Nico breaks down architectures vs. weights, quantization, tokenizers, ONNX, WebGPU, and why on-device AI is the right answer for a huge class of problems. He also shares the road from ski instructor and self-taught web developer to landing what he calls his dream job at Hugging Face.

📖 Read Takeaways
Frontend Foundations at Scale with Giorgio Polvara
Episode 33
55 minutes

Señors @ Scale host Neciu Dan sits down with Giorgio Polvara, Staff Engineer at Perk (formerly TravelPerk), who joined when the company was 15 people in two flats with a hole knocked through the wall and helped build the frontend foundations that still hold up at unicorn scale. Giorgio covers the multi-year migration from a monolithic frontend to vertical micro-frontends, why their first attempt with single-spa didn't work, how they pulled off a full rebrand behind feature flags without leaking, and the staff engineer mindset of treating every feature as a system improvement.

📖 Read Takeaways
Module Federation at Scale with Zack Chapple & Nestor
Episode 32
57 minutes

Señors @ Scale host Neciu Dan sits down with Zack Chapple, CEO and co-founder of Zephyr Cloud, and Nestor, the platform engineer building it, to go deep on module federation, microfrontends, and what it actually takes to go from code to global scale in seconds. They unpack why module federation is Docker for the frontend, how Zephyr composes applications at the edge in 80 milliseconds, and why the real unlock for enterprise teams isn't deployment — it's composition.

📖 Read Takeaways
Back to Blog