Enabling SSR on an existing React + Laravel App

Jake Lee
4 min readJan 4, 2022

Disclaimer: This guide contains code from spatie/laravel-server-side-rendering-examples.

Enabling SSR in an SPA app is beneficial for multiple reasons; faster load times, better user experience, smaller payload. Possibly the biggest benefit, however, is SEO — enabling SSR exposes each generated page to search engines, making it easier for users to reach your website, as well as making it easier for scrapers to scrape your website for search engines. Though unconfirmed, it’s also speculated that Google prioritizes websites with SSR enabled.

It’s a major pain in the ass to enable SSR on an existing React app, since there are a lot of restrictions and things that need to be changed — in fact, it’s my fourth or fifth half-assed attempts to enable SSR, but this time, I’m going to get it right.

The Environment

Currently, Here’s my environment that I’m using:

PHP 7.3NodeJS 14.15.1React 17.0.1

The React version is very important since React 18 may support <Suspense> and React.lazy() in SSR-enabled servers. So there might not be a need to replace all the React.lazy() with regular imports in newer releases.

Installation + Setup

Install spatie’s Laravel SSR package with the composer command:

composer require spatie/laravel-server-side-rendering

Then, using which node (or for Windows, where.exe node), find the NODE_PATHenvironment variable and paste it in to the .env file like so:

NODE_PATH=/absolute/path/to/node/runtime

For Windows, make sure you wrap the path around double quotes and escape the backslashes and wrap and escape the “Program Files” like so:

NODE_PATH="C:\\\"Program Files\"\\nodejs\\node.exe"

Then, we need to start modifying the provided index.js to prepare it for SSR. Currently, it would look something like this:

require('./bootstrap');// other imports...
import React from 'react';
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, document.getElementById('root'));

If you’re using react-router, move the react-router <Router> element outside of the <App /> component. Modify it so that it looks like this:

ReactDOM.render(
<BrowserRouter>
<App />
</BrowserRouter>,
document.getElementById('root')
)

This is necessary because we’ll be using a different Router for client and server files.

Also, make sure to wrap the AppComponent inside withRouter, like so:

const App = () => // ... React elementexport default withRouter(App);

Now, we need to separate it by app-server.js and app-client.js which are respectively, the file that would be run by node runtime and generate the HTML for the runtime & the entry to the React app itself. This will be the client file — rename it to app-client.js (or don’t, it doesn’t matter, but for the sake of clarity, do.)

Create app-client.js in the same directory, and use this snippet:

import App from './router';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
// render to html & dispatch
const html = ReactDOMServer.renderToString(
<StaticRouter location={context.url}>
<App />
</StaticRouter>
);

dispatch(html);

This will render the HTML of the React app (instead of the user rendering it). Now, head over to the entry blade file — for me, it’s index.blade.php . For now, I’ll be removing any code splitting that I did using mix — and consolidating into one big file:

<script defer src="{{ mix('js/app-client.js') }}"></script>

and make sure to put this inside <Body />:

{!! ssr('js/app-server.js')                                                                                                                                                           ->fallback('<div id="root"></div>')                                         ->render() !!}                                                                   

Make sure the div id in the fallback matches with the id of the target div of app-client.js (What’s inside document.getElementById).

Now, open webpack.mix.js:

mix.js('resources/js/app-client.js', 'public/js').react()
.js('resources/js/app-server.js', 'public/js').react()

Make sure to remove any code-splitting options, or extract([]) for extracting vendor files.

Then, modify the routes.php file:

Route::pattern('any', '.*');Route::redirect('/', '/app'); // Not required, but will redirect base requests to app/.Route::view('app/{any?}', 'index');

Make sure you have a prefix, otherwise it will resolve every request as a request to return the blade file. This includes storage/ requests.

Finally, create an empty /ssr folder inside /storage/app/ssr. Now you can use npm run watch and php artisan serve to see how much error it’ll spit out.

Refractoring

For some, it just might work magically — for me, I needed to make some modifications to make it work. Namely, lazy loading doesn’t play well — it spits out incomprehensible errors (for me, it’s saying that self is undefined ) . So we’ll have to sort that out.

First, All React.Suspense has to go. This part’s easy though, you just have to delete the opening and deleting tags.

Second, all lazy imports have to be converted into static imports. This is tedious, but it has to be done until React 18 gets released.

const page = React.lazy(() => import('./pages/page')); // OLD
import page from './pages/page'; // NEW

Resolving undefined Client-side variables on server-side

This is probably the worst of them all; because all the previous errors were a simple find and replace, where this problem will persist throughout future code & old code. For instance, using localStorage will crash the Node instance because no such thing exists in the nodeJS context.

So, for any code using things such as window and localStorage , you’ll need to use something like this:

export function isBrowserContext(){
return typeof window === 'undefined'
}
// example
if(isBrowserContext()){
localStorage.setItem('foo', 'bar');
}

That’s it!

--

--

Jake Lee
0 Followers

No-bullshit guides on how to do things. go hop!