Prerendering React on a Laravel Site Instead of Running an SSR Server
Our marketing pages are React, served by Laravel, and until this summer they scored around 85 on the mobile Lighthouse profile while sitting at 100 for accessibility, best practices, and SEO. Everything cheap had already been done. The hero image was a 1.5 MB PNG that became roughly 40 KB of AVIF behind a responsive <picture>. Each route shipped only its own JavaScript plus a shared chunk. Layout shift was zero and blocking time was 0–20 ms.
First contentful paint and largest contentful paint were both stuck around 3.3 to 3.5 seconds, and nothing in that list was going to move them.
Nothing paints until the JavaScript has run
Every route served the same shell:
<body><div id="app"></div></body>
Nothing is visible until the browser downloads the JavaScript, parses it, executes React, and renders the DOM. The critical path is dominated by the shared vendor chunk — about 63 KB gzipped on our build, mostly react-dom — which loads on every page. Under Lighthouse's mobile throttling, slow 4G with a 4× CPU slowdown, that came to roughly 3.3 seconds before the first pixel of content.
This is why the obvious levers do nothing:
| What you try | What it does to FCP |
|---|---|
| Smaller images | Helps LCP a little, once. Already done. |
| More code splitting | Nothing. The vendor chunk still loads first. |
| Fewer icons, smaller app code | Marginal. react-dom dominates. |
| Rendering HTML before the JS runs | The only thing that moves it. |
Code splitting is the one that catches people out. It's the standard advice, it's genuinely useful for the second and third page a visitor loads, and it cannot help here at all — the chunk that blocks the paint is the one every route needs.
The question that decides which fix you need
Does any of the content depend on who is asking?
On our marketing pages, no. The copy, the cards, and the service data are constants in the React components. There's no per-request data. Everything genuinely dynamic is a client-side enhancement that runs after load anyway: the theme toggle is handled pre-paint by an inline script in the head, the scroll reveal and mobile menu attach in useEffect, and the contact form posts to a Laravel JSON endpoint.
When the answer is no, you don't need a server rendering pages per request. You need the HTML to exist before the browser asks for it, and a build step can do that.
We looked at the alternatives properly before deciding, because "add SSR" is the reflexive answer and it's expensive:
- Inertia with Laravel SSR. A good fit if the roadmap adds authenticated or data-driven pages. It also means adopting Inertia's routing and page-props model across the site and running a long-running Node process in production, to monitor and to restart at 3am. For four static pages, that's a lot of moving parts to gain nothing over a build step.
- A custom Node SSR service. Same operational cost, less framework support.
- Moving the pages to Blade with React islands. Genuinely the fastest possible result. It also means reimplementing the design system's markup in Blade and maintaining two versions of it, which is how designs drift apart.
- Swapping React for Preact via
preact/compat. Cheap, and it does cut the vendor chunk. The page still waits for JavaScript to paint, so it improves the number without fixing the cause.
The build step
Three additions and one change. First, an SSR entry that maps a name to a page component:
export function render(name) {
const Page = pages[name];
if (!Page) {
throw new Error(`Unknown prerender page: ${name}`);
}
return renderToString(createElement(Page));
}
export const pageNames = Object.keys(pages);
Then vite build --ssr compiles that into a bundle Node can import, and a postbuild script walks the routes and writes one HTML file each:
for (const name of pageNames) {
const html = render(name);
writeFileSync(resolve(outDir, `${name}.html`), html);
}
The build command becomes vite build && vite build --ssr && node scripts/prerender.mjs. Blade injects the matching file into the shell, and the four client entries change from createRoot(...).render() to hydrateRoot(...).
Nothing new runs in production. The output is still static files that arrive with a git pull.
Two things that will bite
Anything that reads window at render time. There is no window during the build, so a component that reads window.location.pathname to decide which nav item is current will throw, or worse, render something the browser then disagrees with. Ours reads exactly that, and it needs a typeof window !== 'undefined' guard. Anything inside useEffect is safe, because effects don't run during the server pass.
Your dev environment. This is the part we'd have got wrong without thinking about it. Vite serves CSS through JavaScript in development, so there's no render-blocking stylesheet — which means injecting prerendered markup during dev gives you a flash of completely unstyled content on every page load. The injection is guarded on public/hot not existing, so it only happens for the production build:
if (! file_exists(public_path('hot'))) {
$prerenderName = pathinfo($entry ?? 'resources/js/home.jsx', PATHINFO_FILENAME);
$prerenderFile = resource_path("prerendered/{$prerenderName}.html");
$prerenderedApp = is_file($prerenderFile) ? file_get_contents($prerenderFile) : '';
}
One related habit: don't preload the hero image afterwards. Once the page prerenders, the LCP element is the <h1> text, and a high-priority image preload competes with the CSS that has to arrive before that text can paint.
What it moved
On our own Lighthouse runs against our own site, on the mobile profile: Performance went from around 85 to 93–96 across all four pages, with FCP and LCP dropping from roughly 3.3 seconds to 2.3–2.6. Accessibility, best practices, and SEO stayed at 100. The work took a few days including testing.
Two caveats, because we've since spent enough time watching these numbers to distrust a single run. Mobile Speed Index alone flaps between 98 and 100 on this site with no code change at all, so one run proves nothing in either direction — take three before you believe a regression. And a page that has prerendered still downloads and executes the same React bundle a moment later. Prerendering fixes when the content appears, not how much JavaScript the browser eventually runs.
We kept React on those pages deliberately after measuring it, which is a different decision from never having questioned it. The blog you're reading this on is plain Blade and ships no framework JavaScript at all, because an article has nothing to hydrate.
For what it's worth, the same site loads no third-party JavaScript either — the content security policy that enforces that is a related piece of the same effort.
If you have a Laravel application where the first paint is slow and the usual advice has stopped helping, tell us what you're running and we'll tell you what we think. We do this kind of web application work regularly.
Frequently asked questions
What is the difference between prerendering and server-side rendering?
Server-side rendering builds the HTML for each request, on a server that has to be running. Prerendering builds it once at deploy time and serves the same file to everyone. They produce the same first paint. The difference only matters when a page's content depends on who is asking, in which case prerendering cannot do the job.
Do I need Inertia to server-render React in a Laravel application?
No. Inertia with its SSR mode is a good fit if your pages are data-driven and per-user, but it means adopting Inertia's routing and page-props model and running a Node process in production. If your content is static, a build-time render of the same components needs neither.
Does prerendering help SEO?
Google renders JavaScript, so a client-rendered page can still be indexed. Social crawlers mostly do not — LinkedIn, X and Slack read the initial HTML and nothing else. Prerendering also improves the loading metrics that feed Core Web Vitals, which is the part that affects ranking directly.