Server-Side Rendering With Laravel and Vue.js 2.5
Server-side rendering is a great way to increase the perception of loading speed in your full-stack app. Users get a complete page with visible content when they load your site, as opposed to an empty page that doesn't get populated until JavaScript runs.
One of the downsides of using Laravel as a backend for Vue.js was the inability to server render your code. Was. The release of Vue.js 2.5.0 has brought server-side rendering support to non-Node.js environments including PHP, Python, Ruby, etc.
In this tutorial, I'll take you through the setup steps for Laravel and demonstrate a simple server-rendered app. Get the code for this here on GitHub.
Quick Overview of Server-Side Rendering
If you aren't familiar with server-side rendering (SSR), here's a simple example: say we have a Vue.js app built with components. If we use the browser dev tools to view the page DOM after the page has loaded, we will see our fully rendered app:
<div id="app">
<ul>
<li>Component 1</li>
<li>Component 2</li>
<li>
<div>Component 3</div>
</li>
</ul>
</div>
But if we view the source of the document, i.e. index.html, as it was when sent by the server, you'll see it just has our mount element:
xxxxxxxxxx
<div id="app"></div>
Why the discrepancy? Because JavaScript is responsible for building the page, and, ipso facto, JavaScript has to run before the page is built. Fresh off the server, the page will have no content.
But with server-side rendering, our page includes the HTML needed for the browser to build a DOM before JavaScript is downloaded and run, i.e. the page source would look like the first example above. This is achieved by running the Vue.js app on the server and capturing the output, then injecting that output into the page before it is sent to the user.
With SSR, your app does not load or run any faster, indeed it may run slightly slower as the server has the added task of rendering the app. But the page content is shown sooner, therefore, the user can see engage with the page sooner.
You may also like: Client-Side v/s Server-Side Rendering: What to Choose When?
Why Couldn't Laravel Do Vue SSR Until Now?
Obviously, SSR requires a JavaScript environment on the server, as a Vue app is made with JavaScript. For non-Node.js backends like PHP, Ruby, and Python, a JavaScript sandbox must be spawned from the server to run the Vue app and generate an output.
V8Js is a project that allows you to install the V8 JavaScript runtime within a PHP environment and create such a sandbox. But until Vue version 2.5.0, this was still not adequate as Vue SSR required certain Node.js APIs to run correctly. The recent update has made sure the server renderer is now "environment agnostic" and can, therefore, be run in Node.js, V8Js, Nashorn, etc.
Vue/Laravel SSR Demo
Let's now get a simple demo of Vue SSR in a Laravel app.
Environment
php-v8js is the PHP extension that will give access to Google's V8 JavaScript engine. Undoubtedly the trickiest part of setting up Vue SSR with PHP is getting V8Js installed. Due to my limited Linux knowledge, it, in fact, took me several hours to get it working.
If you have a bit of skill with DevOps, you might try installing it yourself. If not, I recommend you use this Docker image and install Laravel on that.
Installing Dependencies
Once you have the extension working and have a fresh Laravel project, you'll need to install both Vue and vue-server-renderer. You'll need a minimum version of 2.5.0 to get the environment-agnostic SSR features.
xxxxxxxxxx
npm i --save-dev vue@>=2.5.0 vue-server-renderer@>=2.5.0
Vue.js
Let's begin by setting up a simple full-stack Vue.js/Laravel app. This won't have any SSR features yet, but we'll be laying the foundations that we'll need. To start, we'll put the app's main functionality into a single-file component, App.vue.
resources/assets/js/components/App.vue
xxxxxxxxxx
<template>
<div id="app">
{{ message }}
</div>
</template>
<script>
export default {
data() {
return {
message: 'Hello World'
}
}
}
</script>
Our app entry file, app.js, will only be responsible for rendering the component and mounting it to the template. Using a render function here instead of a DOM template is essential for reasons that will soon be clear.
resources/assets/js/app.js
xxxxxxxxxx
import App from './components/App.vue';
import Vue from 'vue';
new Vue({
el: '#app'
render: h => h(App)
});
Mix Configuration
Let's set up a Mix configuration that builds the entry file. Note that I'm also overwriting the default Vue build to use the runtime-only build. Since we're using render functions and single-file components we won't need the template renderer.
webpack.mix.js
xxxxxxxxxx
let mix = require('laravel-mix');
mix
.js('resources/assets/js/app.js', 'public/js')
;
mix.webpackConfig({
resolve: {
alias: {
'vue$': 'vue/dist/vue.runtime.common.js'
}
}
});
With that done, you should be able to build the Vue.js app:
xxxxxxxxxx
$ npm run dev
## Outputs to public/js/app.js
Blade View
We'll need a Blade template to deliver our Vue app to the browser. Make sure to include an empty div
with id app
which will serve as the mount element. Also, include the build script.
resources/views/app.blade.php
xxxxxxxxxx
<html lang="{{ app()->getLocale() }}">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vue/Laravel SSR App</title>
</head>
<body>
<div id="app"></div>
<script src="{{ asset('js/app.js') }}" type="text/javascript"></script>
</body>
</html>
Controller and Route
Let's make a new controller class which will soon include the logic for server rendering the app.
xxxxxxxxxx
$ php artisan make:controller AppController
To begin with, we'll create a method get
that will return our app view:
app/Http/Controllers/AppController.php
xxxxxxxxxx
namespace App\Http\Controllers;
class AppController extends Controller
{
public function get() {
return view('app');
}
}
We'll add a web route for the root path which calls this controller method:
routes/web.php
xxxxxxxxxx
Route::get('/', 'AppController@get');
With that done, we should now be able to view our humble full-stack app:
Server-Side Rendering
The Vue.js app we run in the sandbox must be slightly different to the one we run in the browser, as, although the same language is used, these environments are quite different. For example, there is no window or document object in the sandbox.
We will, therefore, need two builds. These will be as similar as possible but will have some small differences. We'll keep any common (i.e. universal) code in app.js, but any environment-specific code will go into the new entry files we'll create shortly.
In app.js, let's remove the el
property from the Vue config as it makes no sense in a server environment since the app has no document to mount to. We'll also make it so this file exports a function which returns a fresh instance of the app which can be imported into our new entry files.
resources/assets/js/app.js
xxxxxxxxxx
export function createApp() {
return new Vue({
render: h => h(App)
});
}
Entry Files
We now need to create two new entry files, one for the browser (client), and one for the server.
xxxxxxxxxx
$ touch resources/assets/js/entry-client.js resources/assets/js/entry-server.js
The client entry will simply re-implement the functionality we just deleted from app.js, i.e. it will import the universal app and mount it to the template.
resources/assets/js/entry-client.js
xxxxxxxxxx
import { createApp } from './app'
createApp().$mount('#app');
The server entry file is a little more interesting. Firstly, it calls a global method renderVueComponentToString
. This method is exposed by vue-server-renderer which we'll introduce into our SSR setup soon. Secondly, it calls a method print
. This method is part of the V8Js API and is the mechanism for getting something from the JavaScript sandbox back into the PHP environment.
resources/assets/js/entry-server.js
xxxxxxxxxx
import { createApp } from './app'
renderVueComponentToString(createApp(), (err, res) => {
print(res);
});
We'll now need to update our Mix configuration so that we get a build of each version of the app from the two new entry files:
webpack.mix.js
xxxxxxxxxx
mix
.js('resources/assets/js/entry-client.js', 'public/js')
.js('resources/assets/js/entry-server.js', 'public/js')
;
After you run npm run dev
again, you will, of course, have two build files. We'll need to update our Blade view to ensure the new client build file is being loaded instead of app.js:
resoures/views/app.blade.php
xxxxxxxxxx
<script src="{{ asset('js/entry-client.js') }}" type="text/javascript"></script>
If you refresh the page in the browser you should see no difference in behavior yet.
Laravel
We now finally get to the server-side rendering functionality. Add a new method render
to AppController
which works like this:
- The vue-server-renderer module and the server build of the app are loaded from the file system.
- Output buffering is turned on. This means that any output sent from the script is captured internally rather than being printed to the screen.
- Pass some necessary environment variables to V8Js.
- The renderer code and the server build file are then executed. Remember that in entry-server.js we use the
print
method to output something. This will be captured by the output buffer. - Return the buffer contents and delete the current output buffer.
app/Http/Controllers/AppController.php
xxxxxxxxxx
namespace App\Http\Controllers;
use Illuminate\Support\Facades\File;
class AppController extends Controller
{
private function render() {
$renderer_source = File::get(base_path('node_modules/vue-server-renderer/basic.js'));
$app_source = File::get(public_path('js/entry-server.js'));
$v8 = new \V8Js();
ob_start();
$v8->executeString('var process = { env: { VUE_ENV: "server", NODE_ENV: "production" }}; this.global = { process: process };');
$v8->executeString($renderer_source);
$v8->executeString($app_source);
return ob_get_clean();
}
public function get() {
$ssr = $this->render();
return view('app', ['ssr' => $ssr]);
}
}
The value returned from render
will be the server-rendered output of our app. It's an HTML string. We'll now assign this to a template variable and send it to the view. Be sure to skip string escaping by using the {!! !!}
braces so the HTML is printed as-is.
resources/views/app.blade.php
xxxxxxxxxx
<body>
{!! $ssr !!}
<script src="{{ asset('js/entry-client.js') }}" type="text/javascript"></script>
</body>
With that, server-side rendering is now working! If you load the app, though, you probably won't notice any difference, as the page loading improvement on a local server is not going to be perceptible. To confirm it works, view the source of the document and you'll see this:
Rather than the empty <div id="app">
, we have actual content in our page. Note the special attribute that vue-server-renderer adds: data-server-rendered="true"
. This is so that when the Vue instance mounts, rather than trying to re-build the content, it will attempt to mount over it.
Conclusion
Lack of server-side rendering was one of the biggest cons of using Laravel as a Vue.js backend. It's still second-rate compared to Node.js since a sandbox is required, but it's great that it now works.
For more information on Vue SSR, check out the Vue.js Server-Side Rendering Guide.
Further Reading
- The Eight Biggest Laravel Development Mistakes You Can Easily Avoid.
- Reasons Why Laravel Is the Best PHP Framework in 2018.
- Laravel Performance Optimization: Guide to a Perfect Laravel Developer.