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!
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') }}">
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') }}">
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.
- check the existence of variables before accessing these kinds of objects
data() {
name: typeof localStorage !== 'undefined' ? localStorage.getItem('name') : null
}
- 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.