Upgrading to Astro 7: The Rust Compiler No Longer Forgives Sloppy HTML
Table of Contents
Astro 7 shipped on June 22, 2026, and it’s the deepest update the framework has had: the .astro compiler is rewritten in Rust, the Markdown pipeline no longer uses remark/rehype, and the rendering engine moves from recursive to queued.
For most projects the upgrade takes minutes. But there are three changes that break silently — and one of them, the whitespace change, can glue words together in production without a single error to warn you.
I’m writing this from this very site: ortamarco.me runs Astro 7.0.7 in production, with Vite 8, MDX, custom rehype plugins, and the Node adapter. The numbers and the traps below come from here, not from the release notes.
If what you want is an introduction to Astro — what it is, islands, Actions, Content Layer — that’s in the complete Astro.js guide. Here we go straight to the migration.
What changed, in one screen
| Area | Before (Astro 6) | Now (Astro 7) |
|---|---|---|
.astro compiler | Go | Rust, and it’s the only one |
| Markdown / MDX | unified (remark + rehype) | Sätteri (Rust, on pulldown-cmark and Oxc) |
| Rendering | Recursive | Queued, ~2.4× faster |
| Bundler | Vite 7 | Vite 8 (Rolldown) |
compressHTML | true | 'jsx' |
| Routing | Conventional | Advanced Routing with src/fetch.ts |
| Caching | Per adapter | Astro.cache + routeRules |
@astrojs/db | Existed | Removed |
Minimum requirement: Node 22.12.0. If you’re on Node 20 that jump is mandatory anyway — Node 20 hit end of life in April 2026.
The numbers, and what to believe about them
The official announcement claims builds between 15 % and 61 % faster, with some sites building more than twice as fast. The published benchmarks are specific:
| Site | Astro 6 | Astro 7 |
|---|---|---|
| astro.build | 62.70 s | 24.24 s |
| docs.astro.build | 114.54 s | 73.53 s |
| tauri.app | 86.12 s | 55.33 s |
| developers.cloudflare.com | 386.89 s | 261.94 s |
| biomejs.dev | 176.39 s | 149.90 s |
| aspire.dev | 385.84 s | 326.11 s |
Look at that range: astro.build drops 61 %, but biomejs.dev only 15 %. The improvement depends enormously on how much Markdown you have, because the bulk comes from Sätteri, not the compiler. The team itself measures the Rust compiler alone contributing “roughly 6 %” on docs.astro.build.
My measured numbers on this site, right now:
Astro 7.0.7 · Node 24.16.0 · WSL2
102 posts · 163 HTML pages · 125 MB of output
Cold build (no dist, no .astro): 17.69 s (server: 14.06 s)
Warm build: 17.36 s (server: 13.79 s)
Pagefind index: 0.23 s (102 pages, 15,368 words)
Honest caveat: I can’t give you the delta. This site migrated to 7 without leaving a clean measurement of 6 behind, so comparing them would mean making it up. What the figures above are actually good for is scale: a site with a hundred-odd pages, MDX, Preact, Tailwind 4, and two rehype plugins builds end to end in under 18 seconds. If yours is in that ballpark and takes far longer, the problem isn’t Astro.
Trap 1: the Rust compiler no longer fixes your HTML
The Go compiler silently corrected malformed HTML: it closed tags you left open and reordered invalid nesting. The Rust one doesn’t. It treats markup as-is.
That translates into three things:
Unclosed tags are now compile errors. Not a warning: the build fails.
<!-- Astro 6: closed itself, and you never knew -->
<div class="card">
<p>Hello
<!-- Astro 7: compile error -->
Void elements (<br>, <img>, <input>, <hr>) still need no closing tag. Unterminated attributes now blow up too.
Invalid HTML is no longer reordered. If you have a <div> inside a <p>, the compiler used to hoist it out silently to produce valid HTML. Now it leaves it where you put it, and the browser applies its recovery rules — which aren’t the same. The result is that a page can render differently with nothing failing.
This is by far the most treacherous case in the whole upgrade, because there’s no error to chase. If you have old components with questionable nesting, compare them rendered before and after.
CSS serialization changes. Color values and url() formatting may come out written differently. It’s purely cosmetic and doesn’t affect behavior, but if you have snapshot tests over CSS they’ll fail en masse over nothing.
The good news: the first two cases are caught by compiling. The third is caught by reading the diff.
Trap 2: compressHTML goes from true to 'jsx'
This is the one that really bites, and the one I want you to take away even if you read nothing else.
The new default applies JSX whitespace rules: newlines between inline elements no longer produce a visible space.
<span>hello</span>
<em>world</em>
- Astro 6 rendered
hello world - Astro 7 renders
helloworld
In prose and links this is a silent disaster. No error, no warning: your text simply starts coming out with words glued together, and you find out when somebody tells you.
You have two ways out. The right one long term is to adopt JSX rules and make the spaces explicit:
<span>hello</span>{" "}
<em>world</em>
The pragmatic one, and what I chose on this site, is to pin the old value and migrate deliberately later:
// astro.config.mjs
export default defineConfig({
// Astro 7 changed the compressHTML default from `true` to `'jsx'` (strips
// whitespace between inline elements, like React). We pin it to `true` to
// preserve v6 output exactly and avoid words gluing together in prose and
// links. We can migrate to `'jsx'` later, deliberately.
compressHTML: true,
});
That comment is lifted straight from this blog’s configuration. Pinning it turns a content problem — which surfaces late and badly — into a refactor task you do whenever you want.
If you do adopt 'jsx', the serious way to do it is to build twice and compare the served HTML, not eyeball it page by page:
# Build with the old value, save a representative page
npm run build && cp dist/client/blog/your-post/index.html /tmp/before.html
# Switch to compressHTML: 'jsx', rebuild
npm run build && cp dist/client/blog/your-post/index.html /tmp/after.html
Then paste both and see what moved:
Trap 3: Sätteri replaces remark and rehype
Astro 7 swaps the unified pipeline for Sätteri, a Rust processor built on pulldown-cmark and Oxc. On large documentation builds it shaves off more than a minute, and it brings native support for GFM, smart punctuation, heading IDs, containers, math, and frontmatter — things that used to need a plugin each.
The price is that @astrojs/markdown-remark is no longer installed by default. If you depend on remark or rehype plugins, the documentation tells you to install it and set markdown.processor: unified(), or port your plugins to Sätteri’s MDAST/HAST format.
Here’s a nuance I verified in this repo that I haven’t seen documented anywhere. This blog uses two custom rehype plugins:
// astro.config.mjs
markdown: {
syntaxHighlight: {
type: 'prism',
excludeLangs: ['mermaid'],
},
rehypePlugins: [rehypeResponsiveTables, rehypeMermaid],
},
It does not declare markdown.processor: unified() and it works anyway: mermaid diagrams render and tables get their responsive wrapper. The reason is that @astrojs/markdown-remark arrives as a transitive dependency regardless:
$ npm ls @astrojs/markdown-remark
[email protected]
├─┬ @astrojs/[email protected]
│ └── @astrojs/[email protected]
└─┬ [email protected]
└── @astrojs/[email protected] deduped
Translated: if you use @astrojs/mdx, the package comes along for the ride and your rehype plugins keep running. Even so, I’d declare it explicitly in package.json rather than lean on a transitive, because the day Astro stops pulling it in, your build breaks without you having touched anything.
Check yours before assuming:
npm ls @astrojs/markdown-remark
And confirm the plugins actually run by looking at the built HTML, not the dev server:
grep -c "whatever-your-plugin-injects" dist/client/route/index.html
Experimental flags you have to remove
Five features graduated out of experimental. If you had them enabled, they need to come out of the experimental block or the build complains:
| Experimental flag | What to do now |
|---|---|
rustCompiler | Remove. It’s the default and only compiler |
queuedRendering | Remove. Enabled by default |
advancedRouting | Remove. Enabled by default |
logger | Move to the top-level logger field |
cache and routeRules | Move out of experimental, to top level |
src/fetch.ts is now a reserved name
With Advanced Routing, src/fetch.ts becomes a reserved file for configuring the request pipeline. If you already had a file with that name for something else, you have three options:
// Point at another file
export default defineConfig({ fetchFile: './src/router.ts' });
// Or disable Advanced Routing entirely
export default defineConfig({ fetchFile: null });
Or just rename your file. It’s an unlikely collision, but when it happens the symptom is baffling.
What was removed
@astrojs/db is gone entirely and unmaintained. The alternatives are node:sqlite (built into Node, no native compilation), Drizzle ORM, or whatever you already use. This site uses node:sqlite directly for reviews, counters, and leads, and the experience is good: zero native dependencies and WAL enabled by hand.
The internal astro:transitions APIs are no longer exported. Gone are TRANSITION_BEFORE_PREPARATION, TRANSITION_AFTER_PREPARATION, TRANSITION_BEFORE_SWAP, TRANSITION_AFTER_SWAP, TRANSITION_PAGE_LOAD, and the isTransitionBeforePreparationEvent(), isTransitionBeforeSwapEvent(), and createAnimationScope() functions.
They’re replaced by the direct event names, which were always more readable anyway:
document.addEventListener('astro:before-preparation', (e) => { /* ... */ });
document.addEventListener('astro:after-swap', (e) => { /* ... */ });
getContainerRenderer() changes import path. It no longer comes from the package root, but from a dedicated entrypoint:
// Before
import { getContainerRenderer } from '@astrojs/react';
// Now
import { getContainerRenderer } from '@astrojs/react/container-renderer';
The same applies to @astrojs/preact, @astrojs/solid-js, @astrojs/svelte, @astrojs/vue, and @astrojs/mdx.
The new things you’ll actually want
Once past the traps, three things justify the upgrade on their own.
Advanced Routing with src/fetch.ts. Full control of the request pipeline using the standard fetch handler pattern already used by Cloudflare Workers, Deno, and Bun. Useful for intercepting requests, forwarding them to a backend, or composing Astro as middleware alongside something like Hono.
Route caching with Astro.cache and routeRules. A platform-agnostic API with HTTP semantics and tag-based invalidation:
// Declarative, per route group, outside the route code
export default defineConfig({
routeRules: {
'/blog/**': { cache: { maxAge: 3600, tags: ['blog'] } },
},
});
The CDN providers for Netlify, Vercel, and Cloudflare are still experimental.
Queued rendering, ~2.4× faster. It replaces the previous recursive approach. There’s nothing to do: it ships enabled and it shows on sites with deeply nested components.
The upgrade, step by step
# 1. New branch
git checkout -b upgrade/astro-7
# 2. Node 22.12 or higher (24 LTS is the sensible bet)
node -v
# 3. Astro's assisted upgrade
npx @astrojs/upgrade
# 4. Remove the experimental flags that no longer exist
# rustCompiler, queuedRendering, advancedRouting
# and move logger, cache, and routeRules to top level
# 5. Pin compressHTML before building, to isolate problems
# compressHTML: true in astro.config.mjs
# 6. Build. This is where unclosed tags surface
npm run build
# 7. Check your markdown plugins are still alive
npm ls @astrojs/markdown-remark
Step 5 is deliberate: if you upgrade and change whitespace handling at the same time, when something looks off you won’t know whether it was the compiler or the whitespace. Pin true, get the build green, and then decide whether to move to 'jsx'.
One note on final output: if you were already compressing HTML yourself with an external tool, review it — you now have two layers doing the same job with different rules.
Checklist before you deploy
- Node 22.12 or higher on local, CI, and production
-
experimentalfree ofrustCompiler,queuedRendering, andadvancedRouting -
logger,cache, androuteRulesmoved to top level -
compressHTMLpinned explicitly, not inherited - Build green: no unclosed tags or attributes left
-
npm ls @astrojs/markdown-remarkconfirms it’s there, if you use plugins - remark/rehype plugins verified against the built HTML
- No
src/fetch.tsof your own serving another purpose -
getContainerRendererimports pointing at/container-renderer - No leftovers of
@astrojs/dbor the internalastro:transitionsconstants - Pages with questionable HTML nesting compared before and after
Conclusion
Astro 7 is an upgrade worth doing, and on most projects it’s an afternoon’s work. The new capabilities — Vite 8, Sätteri, queued rendering, Advanced Routing, route caching — arrive without demanding a rewrite.
But it’s worth understanding where the problems come from. The two changes that break hardest — the strict compiler and compressHTML — aren’t bugs: they’re the framework no longer covering for things it used to cover for. Badly closed HTML was always wrong; you just find out now. And whitespace between inline elements was always ambiguous; now there’s an explicit rule.
The only one that can slip into production unannounced is the whitespace one. Pin compressHTML: true on day one, and migrate when you have time to read the diff calmly.