Implementing SSR into a Laravel/Vue app

Published 4/6/2019

TLDR: It is possible!

This is meant for those who want to integrate server-side rendering into an existing Laravel Vue application. If you are planning to create a new application, consider using Nuxt.js for a server-side rendered Vue application, with Laravel only serving as an API. If you want to go full Node.js, also consider using Adonis.js instead of Laravel.


PHP does not understand JavaScript. So in order to achieve SSR, we have to spawn a Node.js instance, render our Vue app there and return the output to the client.

Actually, there is already a composer dependency to achieve the task: https://github.com/spatie/laravel-server-side-rendering. You can follow the steps there to implement it. This post will merely deal with the problems I ran into. I will also give some tips along the way.

I am using Laravel 5.5 and Node 8.11. Let's first go over some simple things.

Check out my e-book!

Learn to simplify day-to-day code and the balance between over- and under-engineering.

The blade view

The documentation is a little incomplete in the repository. I was confused with app.$mount('#app') since in the blade files of the readme, there was no element matching the selector #app.

Actually, the complete blade view according to the examples would look like this

step 1. blade

<html>
    <head>
        <script defer src="{{ mix('app-client.js') }}">
    </head>
    <body>
        {!! ssr('js/app-server.js')->fallback('<div id="app"></div>')->render() !!}
        <script defer src="{{ mix('app-client.js') }}">
    </body>
</html>

step 2. Vue

The root component also gets the id app assigned.

<template
  <div id="app">
    <!-- ... --!>
  </div>
</template>

So when SSR fails for some reason it would fall back to <div id="app"></div> and the client-side render would take care of everything.

Otherwise, after the app has been rendered on the server, the client side mount app.$mount('#app') would still work properly because of step 2.

So this works, but having the same ID in multiple places is a little confusing. An easier solution would be to put #app in a wrapper class only in the blade view.

<html>
    <head>
        <script defer src="{{ mix('app-client.js') }}">
    </head>
    <body>
        <div id="app">
            {!! ssr('js/app-server.js')->render() !!}
        </div>
    </body>
</html>

Yes, even with SSR in place, we still need a client-side mount to let Vue add event listeners, deal with all the reactivity and lifecycle hooks. One example would be the mounted method which will only be executed on the client. SSR will only execute what is needed for the initial render.

What's my Node path in .env

In many cases, this might simply be

NODE_PATH=node

You know, the same way you can globally access Node for things like node some-file.js or node -v.

It doesn't perform SSR at all

By default, it is only activated for production. You can change this by first publishing the config file

php artisan vendor:publish --provider="Spatie\Ssr\SsrServiceProvider" --tag="config"

and then changing 'enabled' => env('APP_ENV') === 'production' to 'enabled' => true.


By now it should at least try to perform SSR. That means you are one step closer to finishing it. But now you might encounter problems like the following when Node tries to render the Vue app.

async await is crashing

We are talking about integrating this into an existing application. So be sure to check whether your version of Laravel-mix is not too outdated. In my case, it was not even on 2.0. An update to [email protected] was enough to fix these issues. You might want to consider updating even higher, but then be sure to check the release notes regarding the breaking changes.

All props are undefined in child component

Another error that turned out to be a version error. An update from 2.5 to the latest [email protected] fixed the error. In hindsight, the problem might have also occurred due to having different versions for Vue and vue-server-renderer.

Window is not defined in return window && document && document.all && !window.atob

Now it becomes a little more interesting. You will encounter this error as soon as you have styles in a Vue component. The reason for this is because vue-loder uses style-loader under the hood, which is responsible for dynamically adding the styles to the head during runtime. But there is one problem, it only works in the browser. Since SSR is rendered in Node, there is neither window nor document available. So this got me thinking, how is Nuxt.js doing it? They are also using vue-loader after all. The solution is quite easy: Extract the styles before they are rendered by Node. This is actually a good practice to do so, so let's set it up in laravel-mix.

The only thing we have to do is add the following to the options in webpack-mix.js.

mix.options({
    extractVueStyles: 'public/css/app.css',
})

All styles are being extracted into a single file app.css. If you have individual pages that use Vue and you would like to have a separate CSS file for each page, go with the following:

mix.options({
    extractVueStyles: 'public/css/[name].css',
})

This would create the following files for example

> /public/css/js/login.css
> /public/css/js/registration.css
> /public/css/js/search.css

Apart from extracting Vue styles you also have to remove importing CSS files in JavaScript.

import "some-library/some-style.css"

Instead, move these to some global style sheet. You might already have some merging technique in place for that. Again, it's a good practice to do so anyway ;)

webpackJsonp is not defined

If this happens, you are likely extracting Node modules out into a vendor file. This has various performance benefits.

mix.extract(['vue']);

Why is it crashing? If you look at the output of manifest.js it creates a global variable webpackJsonp and every JavaScript file will access this global variable to resolve the dependencies. Node.js, however, would not get manifest.js as well as vendor.js and therefore would be missing global variables and crash when trying to render your app.

One way to still make use of this feature is to have one webpack.mix.js file for only the server side scripts and another one for the client side scripts. This comment shows how to do exactly that. Unfortunately, that is the only way I know of now how to keep extracting your dependencies.

window / document / $ / localStorage / etc. is not defined

By now, your page might already render correctly, but there are a couple more traps to run into.

Imagine the following

data() {
    name: localStorage.getItem('name')
}

and... crash!

This has nothing to do with the plugin or Laravel at this point, but simply something you have to be aware of when using SSR. window/document/localStorage and much more only exist on the client, not within Node.

There are two workarounds to fix the crash.

  1. check the existence of variables before accessing these kinds of objects
data() {
    name: typeof localStorage !== 'undefined' ? localStorage.getItem('name') : null
}
  1. Move the logic to the mounted method.
data() {
    name: null
},
mounted() {
    // client only
    this.name = localStorage.getItem('name')
}

In Nuxt.js you could also make use of the global process.client boolean to check whether the code is being executed on the server or on the client.

Conclusion

Having to more or less manually set up SSR really makes one appreciate frameworks like Nuxt.js. But the good news is that SSR in Laravel is definitely possible.

If there is any other problem, think: How is Nuxt.js doing it? Because there is certainly a way to do it.