I moved this blog from Next.js to Astro over the last few weeks. The pitch is always the same — ship less JS, own your markup, islands only where you need them. What nobody mentions is the three days you’ll lose to bugs that only exist because you switched runtimes.
1. Satori silently refuses your font
OG image generation worked fine locally, then produced blank images in the build. Turns out Satori — the library astro-og-canvas-style setups lean on — doesn’t support woff2. It wants ttf, otf, or woff. No error, no warning. It just renders text with a fallback font and moves on.
// fails silently
const font = await fetch("/fonts/GoogleSansCode.woff2").then(r => r.arrayBuffer());
// works
const font = await fetch("/fonts/GoogleSansCode.ttf").then(r => r.arrayBuffer());
If your OG images look like they’re using Times New Roman, check the font format before anything else.
2. getPointAtLength doesn’t exist on the server
I had an SVG signature component tracing a path with fontkit, using getPointAtLength to animate stroke position. Worked perfectly in the browser. Broke the build.
Astro renders components server-side by default — and there’s no DOM, so no SVGPathElement, so no getPointAtLength. The fix is guarding the call behind a client directive or computing the points ahead of time with fontkit itself instead of relying on the browser API:
---
// compute path points at build time, not render time
import { getPathPoints } from "../lib/signature";
const points = getPathPoints(text);
---
<svg>{points.map(p => <circle cx={p.x} cy={p.y} r="1" />)}</svg>
Anything that assumes a live DOM is a red flag the moment you’re doing SSR/SSG — it’s an easy habit to carry over from a client-only React codebase without noticing.
3. React idioms don’t map 1:1 — and that’s the point
The actual migration work was mostly mechanical, but the mechanical part is where the interesting decisions live:
| React | Astro |
|---|---|
children | <slot /> |
clsx | class:list |
lucide-react | @lucide/astro |
useSWR | fetch in frontmatter |
None of these are hard swaps. But each one forces a small architectural question: does this need to be interactive? Half my “components” turned out to be static markup wearing a React costume. Astro just makes that obvious, because the default is zero JS and you have to opt back in.
Net result: smaller bundle, fewer moving parts, and a handful of bugs that taught me more about SSR than three years of App Router ever did. If you’re mid-migration and hitting something that “just doesn’t error,” check whether you’re leaning on a browser API or a build-time assumption that doesn’t hold anymore — that’s where all three of mine came from.