Tech 5 min read

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

OptionReason
AstroUsing it elsewhere, great for static sites, works without React
11tySimple but learning curve…
HugoGo-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

ItemBeforeAfter
FrameworkNext.js 16Astro 5
React runtimePresentNone
Bundle sizeLargeMinimal
Iconslucide-reactPure SVG
Dark modenext-themesVanilla JS
FormReact Hook FormVanilla JS
UI libraryshadcn/radix (60 components)None

React completely eliminated.

Specific Conversions

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:

  1. Make it in Figma and put it in public/og.png
  2. 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.