TL;DR: Replaced React with Preact via @astrojs/preact + @preact/compat. Zero component rewrites. Zero regressions. The page bundle dropped from 565 KB to 396 KB — a 30% reduction. Every page with interactive islands now loads 160 KB less JavaScript.
The Setup
This site uses Astro 6 with React islands for a handful of interactive components:
- EngagementDock — like/view tracking on blog posts
- CommandPalette — Cmd+K search overlay
- CopyEmail — clipboard copy button on the contact page
- ToastProvider — ephemeral notification toasts
- FilterControls — category filtering on blog and project listings
- Giscus — GitHub-backed comments on blog posts
- ReadingProgress — scroll progress bar on blog posts
Every one of these is a thin interactive wrapper. None of them use React’s reconciler, virtual DOM diffing, or complex tree reconciliation. They were paying the React tax — ~130 KB for React + ReactDOM — for features they don’t use.
The Migration
The swap took three changes:
1. Swap packages:
- @astrojs/react, react, react-dom, @nanostores/react
+ @astrojs/preact, preact, @preact/compat, @nanostores/preact
2. Swap integrations:
- react({ include: '**/*.{tsx,jsx}' })
+ preact({ compat: true })
3. Swap one import path:
- import { useStore } from '@nanostores/react'
+ import { useStore } from '@nanostores/preact'
That was it. @preact/compat aliases react to preact/compat at the Vite level, so every existing import { useState } from 'react' resolves to Preact’s hooks implementation. Zero component files were touched beyond that one import path change.
The Results
| Metric | Before (React) | After (Preact) | Change |
|---|---|---|---|
| Total JS payload | 565 KB | 396 KB | -169 KB (-30%) |
| Framework runtime | ~160 KB | ~19 KB | -141 KB (-88%) |
| Per-page JS (with islands) | 321 KB | 201 KB | -120 KB (-37%) |
| E2E tests passing | 25/25 | 25/25 | No regressions |
The breakdown of the new page bundle:
| Chunk | Size | What |
|---|---|---|
page.*.js | 201 KB | Sentry SDK + shared vendor runtime |
preact.module.*.js | 10.4 KB | Preact core |
signals.module.*.js | 7.7 KB | Preact signals (nanostores integration) |
EngagementDock.*.js | 7.2 KB | Like/view tracking |
CommandPalette.*.js | 6.4 KB | Search overlay |
jsxRuntime.module.*.js | 5.7 KB | Preact JSX transform |
hooks.module.*.js | 2.7 KB | Preact hooks (useState, useEffect, etc.) |
client.js | 2.6 KB | Preact client runtime |
| Other components | ~7 KB | ToastProvider, CopyEmail, Giscus, Filters |
The biggest win is React + ReactDOM going from ~160 KB to Preact’s ~19 KB. The second win is the Astro client runtime dropping from 182 KB (React-specific) to 2.6 KB (Preact-specific).
Tradeoffs
Preact is not a drop-in replacement for every React app. Some edge cases:
Suspense— Not in Preact. We weren’t using it.createPortal— Available in compat. We weren’t using it.- Third-party React libraries — Most work through compat, but exotic ones may not.
- TypeScript types — Preact has its own types. Compat provides React-compatible types.
For this site — a content blog with thin interactive islands — Preact is a strict upgrade. The same JSX, the same hooks, the same mental model. Just less of it.