Migrating a 3-App Astro Monorepo from 6 to 7: What Actually Broke

The real upgrade log for moving a Bun monorepo (portfolio, DSA platform, arcade) from Astro 6.4 to 7.0 — the Rust compiler, Vite 8 + Rolldown, the Sätteri markdown switch, and the three things that actually broke the build.

Most “how I upgraded” posts are a changelog with extra words.

This is the upgrade log I wished existed before I started: three Astro apps in one Bun monorepo — a portfolio, a DSA/system-design platform, and a PixiJS arcade — moved from Astro 6.4.2 to 7.0.2 in one branch. What broke, why it broke, and the exact fix. No theory. The build is the source of truth, and the build told me everything.


The numbers first

Full clean build of all three apps, before and after, plus gzip bundle totals per app:

Astro 6.4.2Astro 7.0.2
Full build (3 apps)~34.5s~16.1s
main — JS / CSS (gz)50.1 / 114.9 KB49.0 / 113.9 KB
grind — JS / CSS (gz)45.4 / 46.2 KB44.2 / 46.0 KB
arcade — JS / CSS (gz)301.4 / 68.5 KB289.8 / 69.5 KB

Build time roughly halved. Bundles came out flat-to-slightly-smaller — the arcade’s PixiJS JS graph dropped ~11.6 KB gzipped, which I credit to Rolldown. (One caveat on honesty: the build time is a single sample and the first run re-optimizes dependencies. Directionally it matches Astro’s own benchmarks, but don’t treat “2×” as a guarantee.)


What’s actually new in 7

Three things matter:

  1. The Rust compiler is now the only compiler. The Go-based .astro compiler is gone. The replacement is faster — and stricter. It does not auto-correct invalid HTML anymore.
  2. Vite 8 ships Rolldown — a Rust bundler replacing esbuild + Rollup. This is most of the speed win and most of the integration risk.
  3. Sätteri is the default markdown processor, replacing the unified/remark/rehype pipeline for .md and .mdx.

Everything that broke for me traces back to #1 and #3.


Break #1: 116 self-closing <script> tags

This was the big one. The build died immediately:

[CompilerError] Unexpected token
  Location: src/pages/git/visualizer.astro:635:0

Line 635 was the end of the file. An “unexpected token” at EOF almost always means something earlier never closed. The actual culprit was 600 lines up:

<!-- invalid: script is a raw-text element, it cannot self-close -->
<script type="application/ld+json" is:inline set:html={JSON.stringify(jsonLd)} />

HTML spec: <script> is a raw-text element. <script .../> is not a real self-close — the browser (and now the Rust compiler) reads it as an opening tag whose content runs until the next </script>. The old Go compiler quietly tolerated the />. The Rust compiler follows the spec, so that one JSON-LD tag swallowed everything down to the page’s real </script>, and the parser fell off the end of the file.

I had 116 of these across 48 files — every structured-data block on every SEO page. The fix is mechanical: convert each self-closing script to an explicit close.

<script type="application/ld+json" is:inline set:html={JSON.stringify(jsonLd)}></script>

A regex sweep that can’t cross a > boundary does it safely without touching normal <script> blocks or already-closed tags:

src.replace(/(<script\b[^>]*?)\s*\/>/g, "$1></script>")

Lesson: if you generate JSON-LD with set:html and a self-closing tag (a very common Astro pattern), you will hit this. Grep for it before you upgrade.


Break #2: a missing </Layout>

Same file, after fixing the scripts, same error at EOF. Different cause. The page opened <Layout> on line 30 and never closed it — the final </Layout> simply wasn’t there.

Astro 6’s Go compiler auto-closed dangling elements at EOF. Astro 7 does not: every non-void element needs a matching closing tag. So a bug that had been silently “corrected” for the file’s entire life became a hard error. One line fixed it:

</script>
</Layout>

Lesson: the strictness will surface latent, long-standing markup bugs. That’s a feature. Don’t blame the upgrade — it found a real defect.


Break #3: the Sätteri default vs. a custom remark plugin

The main app has a tiny remark plugin that exposes raw markdown so blog pages can compute reading time:

function remarkRawContent() {
  return (tree, file) => {
    file.data.astro.frontmatter.rawContent = file.value;
  };
}

Under Astro 7, .md/.mdx render through Sätteri by default, and remark plugins don’t run. Reading-time would silently fall to zero — no error, just wrong output. That’s the dangerous kind of breaking change: the build stays green while the page lies.

I didn’t want to port the plugin to Sätteri’s MDAST API just to keep one feature, so I opted that app back into the unified pipeline:

// astro.config.mjs
import { unified } from "@astrojs/markdown-remark";

export default defineConfig({
  markdown: {
    processor: unified({
      remarkPlugins: [remarkRawContent],
    }),
  },
});

This forgoes Sätteri’s speed for main’s markdown — a deliberate trade. The two apps without custom remark plugins keep Sätteri and its faster pipeline. I verified the opt-back actually worked by checking a built page rather than trusting the green build: 15 min read rendered, so rawContent was populating again.

Lesson: for any breaking change that affects output rather than compilation, verify the rendered artifact. A passing build is necessary, not sufficient.


The non-events

Plenty of 7.0’s breaking changes didn’t touch this repo, and it’s worth saying so — the migration was smaller than the changelog made it look:

  • compressHTML default changed from HTML-aware to 'jsx' (which strips whitespace between inline elements). All three apps already set compressHTML: true explicitly, so the new default never applied. An accidental config habit saved a whole category of whitespace regressions.
  • @astrojs/db was removed. Not used.
  • astro:transitions internal helpers were removed (createAnimationScope, the TRANSITION_* constants). I only use the <ClientRouter> component, which is untouched.
  • src/fetch.ts is now reserved for advanced routing. No collision.
  • Experimental flags graduated. None were set.
  • @tailwindcss/vite + Rolldown — the risk I worried about most (there had been a tsconfigPaths binding error on Rolldown-Vite) was a non-event on the current versions. It just built.

Integration version bumps were minimal: @astrojs/mdx to ^7, @astrojs/check to ^0.9.9, and @astrojs/sitemap/@astrojs/rss were already current with no Astro peer pin.


How I’d tell you to do it

  1. Branch. Capture a before-baseline build (times + bundle sizes) you can diff against.
  2. Read the v7 upgrade guide and grep your repo for each breaking change — don’t guess which ones apply, prove it.
  3. Bump astro and official integrations to v7-compatible versions; let the lockfile resolve peers.
  4. Build, and let the compiler find the strictness bugs. It’s fast and it’s right. Fix file by file. The self-closing-script and unclosed-tag classes are the most likely hits.
  5. For any output-affecting change (markdown processor, whitespace), inspect the rendered file, not just the exit code.
  6. Diff after vs. before. Ship.

Node 22.12+ is required (even-numbered releases only). Beyond that, for a content-heavy static site with no @astrojs/db and no deep Vite-internal plugins, Astro 7 was a half-day upgrade whose hardest part was a markup bug from 2024 that the old compiler had been hiding.

The build got twice as fast and the bundles got smaller. Worth it.

If this article helped you think differently, consider adding karma. 🪔 Add Karma →

☽ Try The Scroll