Back to Blog

Netlify: prerendering to Astro
(a migration)

My own site was serving Googlebot 2,280 bytes and an empty div. A four-hour migration fixed it, and turned up soft 404s, an invisible H1 and a font chain I had not noticed.

Netlify: prerendering to Astro | (a migration)

I sell SEO services. My own site was serving Googlebot an empty div.

I had built it as a TypeScript single page application with Vite and React, deployed to Netlify. It looked fine. It worked fine. And when I finally ran a crawler against it, this is what came back:

html
<body> <div id="root"></div> </body>

Two thousand two hundred and eighty bytes. No heading, no copy, no content of any kind. Everything on the page existed only after JavaScript executed in the browser.

Google does render JavaScript, so this is not automatically fatal. But it happens on a separate, slower queue, and the results are inconsistent. For a site whose entire job is to demonstrate that I can win search traffic, it was not a defensible position.

Here is what I did about it, what went wrong, and what the migration turned up that I had not expected.

Check your own site first

One command, substituting your domain:

bash
curl -s https://yoursite.com/ | head -60

If your page copy is in there, you are fine. If you see an empty root div, read on.

The first trap: a toggle that did nothing

My initial instinct was the obvious one. Netlify offers prerendering, which renders your page in a headless browser and serves the result to crawlers. I turned it on.

Nothing changed.

The reason took a while to find. Netlify's original built in prerendering feature was deprecated in December 2025 and stopped working for free plan accounts on 20 January 2026. I had enabled a switch that no longer did anything. The interface gave no indication of this.

So I installed the replacement, the Prerender extension. Still nothing. A Googlebot user agent returned the same empty body.

The second trap: two solutions fighting each other

The curl output had a clue I had initially skimmed past:

html
<!-- SEO Meta Tags (injected by Edge Function for crawlers) -->

I had, at some earlier point, written a custom Netlify edge function to inject Open Graph tags and canonical URLs for crawlers. It was still running.

Netlify edge functions execute as a chain, and each one must call context.next() to pass control onwards. Mine was fetching the page, injecting its tags, and returning a response. The chain terminated there. The Prerender extension never got a look in.

Two mechanisms competing over the same crawler requests, and the wrong one was winning.

The output had two other problems I had not noticed either. The injected tags produced a duplicate <title> and a duplicate meta description, because the originals were still in the document head. And the whole response, all 2.2 KiB of it, was taking roughly two seconds to arrive.

Why I stopped debugging and migrated instead

At this point I had spent a few hours on a layer whose only purpose was to fake what static rendering gives you for nothing.

That is the actual argument against prerendering, and it is worth stating plainly. Prerendering depends on user agent detection, so your site behaves differently depending on who is asking. New crawlers and AI agents have to be on the list. The headless browser can fail silently and return the empty page anyway. Edge functions can shadow each other. Every crawler request goes through an interception layer, and a cache miss means waiting on a render.

None of those failure modes exist if the HTML is simply generated at build time.

A brief introduction to Astro

Astro is built on a straightforward premise: most websites are mostly static, so most websites should ship mostly static HTML.

Where React sends a JavaScript bundle that constructs the page in the visitor's browser, Astro renders pages to plain HTML during the build. The visitor receives a finished document, and so does every crawler, with no special handling for either.

Three things made it the right choice for this particular migration.

It is built on Vite, which my project already used. It supports TypeScript with strict mode out of the box. And it runs React components natively, so the existing work did not have to be rewritten from scratch.

Astro calls this the islands architecture: static HTML by default, with individual components opted back into interactivity where genuinely needed.

The migration took four hours

That number comes with a caveat I will get to, but the core work was genuinely one morning.

bash
npm create astro@latest my-site -- --template minimal --typescript strict cd my-site npx astro add react sitemap

No Netlify adapter. The adapter is for server side rendering, and static output is the entire point.

js
// astro.config.mjs import { defineConfig } from 'astro/config'; import react from '@astrojs/react'; import sitemap from '@astrojs/sitemap'; export default defineConfig({ site: 'https://yoursite.com', integrations: [react(), sitemap()], });

Setting site gives you correct canonical URLs and a generated sitemap for free.

The layout is where metadata lives, defined once rather than injected afterwards:

astro
--- // src/layouts/Layout.astro interface Props { title: string; description: string; } const { title, description } = Astro.props; const canonical = new URL(Astro.url.pathname, Astro.site); --- <!doctype html> <html lang="en-GB"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>{title}</title> <meta name="description" content={description} /> <link rel="canonical" href={canonical} /> <meta property="og:title" content={title} /> <meta property="og:description" content={description} /> <meta property="og:url" content={canonical} /> <meta property="og:type" content="website" /> </head> <body><slot /></body> </html>

One source for each tag means the duplicate title problem cannot recur.

Routes become files under src/pages/, so the client side router goes away entirely. Match your existing URLs exactly, or you will lose whatever rankings you have.

After the build, the same curl returned 13,454 bytes with the actual page copy in the body. Crucially, that was with a plain request and no Googlebot user agent. Identical output for humans and crawlers, which is the whole point.

The caveat: four hours bought crawlability, not performance

I lifted the React application into Astro rather than converting it. Both the layout and the page components were still React islands mounted with client:load, hydrating immediately on every page.

That is a legitimate trade and it is why the migration was four hours rather than two days. The urgent problem was fixed. But the full React bundle was still downloading for pages that are almost entirely static, so none of Astro's performance benefit had actually been claimed.

Converting components to .astro is incremental work that can happen afterwards. Just be clear with yourself about which problem you have solved.

What the migration exposed

The follow up work took roughly another day, and most of it was fixing things the single page application had been hiding.

Every metadata tag vanished. Removing the edge function removed the canonical, Open Graph, Twitter and JSON-LD tags it had been injecting. I had to rebuild them properly in the layout. For a short period the site was worse off than before the migration, which is worth planning for rather than discovering.

The site returned 200 for everything. A nonexistent URL returned HTTP 200 with content. A leftover catch all rule from the single page application era was serving the app for any unmatched path, which Google treats as a soft 404. Across 66 pages that is a meaningful drag on crawl efficiency, and I had no idea it was happening. I found it by accident, running a test with a placeholder filename I had forgotten to substitute.

The hero was invisible to the renderer. The H1 was server rendered with inline opacity:0, waiting on a JavaScript entrance animation. The content was crawlable, but Largest Contentful Paint measures when the element actually paints, so nothing could paint until React hydrated. Moving the animation to pure CSS inside a @media (prefers-reduced-motion: no-preference) block fixed it, and made it fail visible rather than fail blank.

Fonts were loading the slowest possible way. The first line of my stylesheet was a CSS @import from Google Fonts. The browser had to fetch the stylesheet, parse it, discover the import, fetch Google's CSS, parse that, then fetch the font files. Four sequential blocking round trips. Self hosting removed the chain and a GDPR question at the same time.

Testing properly

The curl test. Run it with a Googlebot user agent and then without:

bash
curl -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \ https://yoursite.com/ | head -60 curl -s https://yoursite.com/ | head -60

The two responses should be identical. That equivalence is the thing you are actually buying.

Check your 404s, which I nearly did not:

bash
curl -sI https://yoursite.com/definitely-not-a-real-page-12345 | head -1

You want 404. If it says 200, you have a catch all rule to remove.

Search Console. Curl tells you what your server sends. Search Console tells you what Google received, which is the answer that matters. Use URL Inspection, select Test live URL, then View crawled page. Your content should be in the raw HTML, not merely in the rendered screenshot. Submit your sitemap while you are there.

Was it worth it?

The honest answer is that I do not know yet.

The scores improved, measurably. But scores are not traffic. The site had no sitemap and no reliably crawlable HTML before this, so Search Console was only configured once the work was done. Ranking and indexing data will take weeks to mean anything, and I would be making it up if I claimed otherwise today.

What I can say is that the technical reasons the site could not rank have been removed. That is a precondition rather than a result, and anyone telling you a migration like this produces traffic on its own is selling something.

Prerendering remains reasonable for genuinely dynamic applications: dashboards, authenticated tools, anything unknowable at build time. If that is your project, use the extension and watch its logs.

But if your site is fundamentally a set of pages that rarely change, you are maintaining a complicated workaround for a problem that static rendering avoids having in the first place. I spent several hours debugging a bot detection layer before concluding that four hours of migration would have been the better opening move.