React2Shell Was Such a Headache I Migrated from Next.js to Astro
Background
This is about a corporate site I manage for work.
In December, React2Shell was discovered. CVSS 10.0. Server takeover possible.
I patched immediately. Updated to 19.0.1.
A few days later, “the initial fix was incomplete” — a DoS vulnerability was additionally disclosed. Updated to 19.0.2.
A few more days later, “19.0.2 was still vulnerable.” Updated to 19.0.3.
I’m done.
Does This Even Need React?
Thought about it calmly. The site in question:
- 5-page static site
- One contact form
- Dark mode toggle
- Dynamic OG image generation
Does this need React 19 Server Components? No.
Looking closer at the dependencies, I had installed 60 shadcn UI components — and the only one actually being used was AlertDialog.
Peak over-engineering.
Choosing a Migration Target
| Option | Reason |
|---|---|
| Astro | Using it elsewhere, great for static sites, works without React |
| 11ty | Simple but learning curve… |
| Hugo | Go-based, fast, but passing this time |
Went with Astro. Simple reason: this blog runs on Astro and I’m already familiar with it. Unifying work sites to Astro makes management easier.
Migration Strategy
Rather than rewriting the existing project, the approach was to create a new project and migrate incrementally.
pnpm create astro@latest corporate-site-astro
Reasons:
- Avoids dependency conflicts
- Existing site stays live until migration is complete
- Start from a clean state
Tech Stack Comparison
| Item | Before | After |
|---|---|---|
| Framework | Next.js 16 | Astro 5 |
| React runtime | Present | None |
| Bundle size | Large | Minimal |
| Icons | lucide-react | Pure SVG |
| Dark mode | next-themes | Vanilla JS |
| Form | React Hook Form | Vanilla JS |
| UI library | shadcn/radix (60 components) | None |
React completely eliminated.
Specific Conversions
Navigation
Before (Next.js)
'use client';
import { usePathname } from 'next/navigation';
export function Navigation() {
const pathname = usePathname();
const isActive = (href: string) => pathname === href;
// ...
}
After (Astro)
---
const pathname = Astro.url.pathname;
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/');
---
The usePathname hook is gone. In Astro, the path is determined at build time, so no client-side JavaScript is needed at all.
Dark Mode
Before (next-themes)
import { ThemeProvider } from 'next-themes';
// layout.tsx
<ThemeProvider attribute="class" defaultTheme="system">
{children}
</ThemeProvider>
After (Vanilla JS)
<script is:inline>
const theme = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.classList.toggle('dark', theme === 'dark');
</script>
is:inline excludes it from Astro’s bundle and embeds it directly in the HTML. This prevents the flash of unstyled content (momentary white flash).
Form
Before (React Hook Form)
const { register, handleSubmit, formState } = useForm();
const onSubmit = async (data) => {
// reCAPTCHA verification
// fetch submission
// state update
};
After (Vanilla JS)
const form = document.getElementById('contact-form');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
// reCAPTCHA verification
// fetch submission
// show result via DOM manipulation
});
React Hook Form’s abstraction is convenient, but plain JavaScript is more than enough when there’s only one form.
AlertDialog
The shadcn UI AlertDialog I was using turned out to just be a modal when I looked at the implementation.
<dialog id="alert-dialog">
<p id="alert-message"></p>
<button onclick="this.closest('dialog').close()">Close</button>
</dialog>
<script>
function showAlert(message) {
document.getElementById('alert-message').textContent = message;
document.getElementById('alert-dialog').showModal();
}
</script>
The HTML <dialog> element was all that was needed.
OG Image Generation
Next.js’s next/og (Vercel’s Satori) was convenient, but for a static site, pre-generation is fine.
Options:
- Make it in Figma and put it in
public/og.png - Write a build-time generation script with sharp
Went with option 1 this time. Only 5 pages.
Migration Results
Build Output Difference
Next.js (SSG)
out/
├── _next/
│ ├── static/
│ │ ├── chunks/
│ │ │ ├── framework-*.js # React itself
│ │ │ ├── main-*.js # Next.js runtime
│ │ │ ├── pages/_app-*.js
│ │ │ ├── pages/_error-*.js
│ │ │ └── webpack-*.js
│ │ └── css/
│ └── data/ # Pre-rendered data
├── index.html
├── about.html
├── 404.html
├── _buildManifest.js
├── _ssgManifest.js
└── ...
Astro
dist/
├── index.html
├── about/index.html
├── contact/index.html
├── services/index.html
├── _astro/
│ └── *.css # CSS only
└── images/
Even with static output, Next.js puts React runtime and webpack chunks in _next/static/chunks/. A 5-page site was generating 20+ JavaScript files.
Astro has zero JavaScript files. Since no client-side JS was used in this configuration, _astro/ contains only CSS.
File Structure
corporate-site-astro/
├── src/
│ ├── layouts/Layout.astro
│ ├── components/
│ │ ├── Navigation.astro
│ │ └── Footer.astro
│ ├── pages/
│ │ ├── index.astro
│ │ ├── about.astro
│ │ ├── contact.astro
│ │ └── services/
│ │ ├── index.astro
│ │ └── web.astro
│ └── styles/globals.css
└── public/
├── send_mail.php
└── images/
Dependencies
Before
{
"dependencies": {
"next": "^16.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@radix-ui/react-alert-dialog": "...",
"@radix-ui/react-dialog": "...",
// ...60 shadcn UI components
"lucide-react": "...",
"next-themes": "...",
"react-hook-form": "..."
}
}
After
{
"dependencies": {
"astro": "^5.0.0",
"@astrojs/tailwind": "...",
"tailwindcss": "^4.0.0"
}
}
The node_modules size dropped to roughly a third.
The React2Shell situation prompted a re-evaluation of whether React was even needed at all:
- React 19 was overkill for a 5-page static site
- 60 shadcn UI components installed, used exactly one
- Vanilla JS was sufficient
Repeatedly updating and praying every time a vulnerability surfaces is less healthy than just reducing the attack surface. Astro supports “use React only when needed,” so if interactive features are needed in the future, they can be added partially. For now, they’re not.