← blog Leggi in italiano
EN 5 min read

From Create React App to Astro: migrating my portfolio without rewriting a single React component

Why I ditched CRA, what it means to choose Astro for an existing React project, and the concrete problems I solved along the way: SVGs in Cloudflare's workerd, islands architecture, Vite aliases.

astroreactcloudflareviteperformance

My portfolio was a classic React app, built with Create React App years ago. It worked. It wasn’t fast, it had no SEO, it had no blog — but it worked. When I decided to add a technical blog, I faced a choice: keep stacking complexity on top of CRA, or change the foundation.

I chose to change the foundation.

Why not CRA

Create React App was born as a teaching tool and prototyping starter. By 2024/2025 it’s effectively deprecated — the official repository hasn’t received meaningful updates, and the community has moved on to Vite, Next.js, and more modern frameworks. Using it for a personal site that also needs to generate static pages and host MDX content means working against the tool, not with it.

The alternatives were two: Next.js or Astro.

Why Astro and not Next.js

Next.js would have been the obvious choice — I use it every day at work. But my portfolio doesn’t need SSR, API routes, or edge functions on every page. It needs:

  • An interactive React app (the Web OS desktop simulation) running exclusively in the browser
  • Static pages for the blog (HTML generated at build time)
  • No superfluous JavaScript where it’s not needed

Astro is built exactly for this. Its model is zero JS by default: every page is static HTML unless you explicitly declare otherwise. React becomes an island — a component that hydrates only where and when it’s needed.

The architecture: React as an island

The most important part of the migration was understanding the Astro island concept. My entire React app (the full OS desktop simulation) lives in a single component called OsInterface. In Astro, this becomes:

---
import OsInterface from '../components/OsInterface';
---
<OsInterface client:only="react" />

The client:only="react" directive means: don’t render this component on the server, load and hydrate it only in the browser. It’s the right choice for an app that relies on window, document, localStorage — things that don’t exist in a Node.js context.

The first version used client:load, which pre-renders on the server instead. The result was a cryptic React 19 error: SVG imports returned non-string objects during SSR, and React refused to pass them as src to an <img> tag.

Switching client:load to client:only="react" fixed the error in a single line.

The SVG problem inside Cloudflare’s workerd

With client:only the React error disappeared, but icons were still invisible. Dev server clean, no console errors — SVGs simply weren’t loading.

The problem was the Cloudflare adapter.

Astro with @astrojs/cloudflare runs the dev server inside a workerd environment — Cloudflare Workers’ runtime — instead of standard Node.js. In this environment, files imported through Vite get URLs like /@fs/absolute/path/to/file.svg. These URLs work fine in a normal Vite context, but workerd doesn’t serve them reliably.

The fix was moving all static assets to the public/ folder. Files in public/ are served directly as static files at absolute URLs (/assets/svg/logo.svg) — no Vite pipeline involved, no runtime issues.

// Before — Vite import, broken in workerd
import Logo from '../../../assets/svg/logo.svg';

// After — absolute URL, works everywhere
const Logo = '/assets/svg/logo.svg';

I applied this transformation to every component that imported SVGs or PDFs.

Vite aliases

CRA used tsconfig.json with baseUrl: "src" to resolve absolute imports (import X from 'utils/isMobile'). Vite supports the same pattern, but in Astro the correct approach is to declare aliases explicitly in astro.config.mjs:

vite: {
  resolve: {
    alias: {
      App:        path.join(srcDir, 'App'),
      components: path.join(srcDir, 'components'),
      config:     path.join(srcDir, 'config'),
      // ...
    },
  },
},

This avoids relying on vite-tsconfig-paths (an additional plugin) and makes the aliases explicit and controlled.

The MDX blog

Once the React side was sorted, adding the blog was surprisingly straightforward. Astro has a built-in content collections system:

// src/content.config.ts
const blog = defineCollection({
  loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
    tags: z.array(z.string()).optional(),
    lang: z.enum(['it', 'en']).default('en'),
    translationSlug: z.string().optional(),
  }),
});

Every .mdx file inside src/content/blog/ automatically becomes a typed article. The [slug].astro page fetches the content and renders it:

---
const { post } = Astro.props;
const { Content } = await render(post);
---
<Content />

No webpack config, no babel plugins, no hidden magic.

The result

The final structure:

  • / — full React app, loaded as an island with client:only="react"
  • /blog — static article list, zero JavaScript
  • /blog/[slug] — MDX article rendered to static HTML

The build runs on Cloudflare Pages with their static adapter. Build times under 30 seconds, instant deployment to the global CDN.

The entire migration kept the React code untouched. Zero components rewritten, zero Redux refactoring. Astro simply did its job: coordinating the static parts and letting React handle the interactive ones.

Sometimes the right move isn’t to rewrite everything. It’s to pick the right tool for the right layer.