Posted on February 11, 2019
These days there are two common architectural approaches to modern web app development:
My primary goal with this article isn't to compare these two approaches. If you're interested in learning more about each approach (and others), I highly recommend this article by Jason Miller and Addy Osmani from Google. Rather, I'd like to introduce a technique I've been using lately that blends my favourite parts of each approach: using client-side rendering in a server-side app.
Let's get straight to the point. My latest approach is this:
What I have here is a classic server-side app, that uses server-side routing and controllers. There is no API. The controllers lookup the data from the database, and then pass it to the templates. Except, I'm not using any server-side (ie. Blade) templates. Instead I'm doing full client-side rendering using Vue.js. Confused yet? Let me explain.
Put simply, I can't imagine building a modern web app without some type of JavaScript rendering system. More than ever, web apps require a ton of JavaScript for a vast array of possible interactions. From advanced form controls like date inputs and file uploads, to drop-down menus, modals, animations, loading indicators, and much more. This is obviously a major contributing factor the popularity of modern single-page apps.
Prior to going all in on client-siding rendering, I would use server-side rendered templates, and then layer on JavaScript functionality on top using Vue components. This approach should sound very familiar to anyone who did web development during the jQuery days. Each of my pages started with a server-side (Blade) template. When I needed some interaction, I'd create a new Vue component and drop it into the Blade template. If that component needed data, I'd pass that data to the component as props. Here's an example:
<html>
<head>
// ...
</head>
<body>
<h1>{{ $event->title }}</h1>
<div>{{ $event->date->format('F j, Y') }}</div>
<div>{{ $event->description }}</div>
<div>{{ $event->category->name }}</div>
<edit-event
:event="{{ json_encode([
'id' => $event->id,
'title' => $event->title,
'date' => $event->date->format('F j, Y'),
'description' => $event->description,
'category_id' => $event->category_id,
]) }}"
:categories="{{ $categories->map->only(['id', 'name']) }}"
>Edit</edit-event>
</body>
</html>
The end result was an app that mixed two fundamentally different approaches to rendering. It was messy and often confusing. There wasn't a single place to find templates. Passing prop data to the Vue components was a mess (as you can see above). It was a constant struggle trying to predict if something should be a server-side template, or become a client-side component. Would it need some JavaScript interaction, or would a plain server-side template suffice? Not an ideal workflow.
As I worked with Blade and Vue together, an interesting pattern started to appear. I noticed that for some pages, I was actually already building them as a single Vue component. These were pages that required a lot of JavaScript interaction, or at least needed to live within the scope of a Vue component so that I could hide or show data depending on some state. My server-side templates started looking like this:
<html>
<head>
// ...
</head>
<body>
<create-event :categories="{{ $categories->map->only(['id', 'name']) }}"/>
</body>
</html>
Basically just an empty body with a single Vue component. Looks familiar right? It looks a lot like a single-page app. At this point you might be thinking "Ah-ha! Jonathan, you just proved that single-page apps are better!"
Not so fast.
While I agree that client-side rendering is a huge win, I'm not convinced that means I should give up server-side routing, nor that I should go off and write an API.
These days, if you're building a rich-client web app, it's almost assumed that you have a REST or GraphQL API backing it, as well as client-side routing. But, you can absolutely accomplish the same thing with classic server-side routing and controllers.
The difference is quite simple: instead of the front-end being responsible for requesting data from an API, the server is responsible for giving the front-end components their data via props.
Maybe I'm old school, but I still love the simplicity of a classic server-side MVC setup. There's no state management complexity to deal with (sorry Vuex and Redux). Each page refresh gives you a fresh slate. There's no fighting with browser history APIs. You just click links, and the browser takes care of that "for free". Even handling 404 responses and other responses is dead simple, which is not the case with client-side routing.
And, there are some nice performance wins that come with using this approach. You have full access to the underlying database and can run very specific queries to get exactly the data needed for each page in your app.
You get all of the benefits of a GraphQL API without ever having to write one.
Frankly, if your application needs an API, maybe because it's multi-platform, or maybe because you want to provide 3rd party API access, then this approach might not be right for you.
For me, this isn't the case for most of my projects. Most of them are web apps that will never be multi-platform. And even if they are, I still have choices. I could build a complementary API when that requirement arises. Or, I could sidestep the problem entirely using a technique like Turbolinks. The point is, I shouldn't have to start with an API just because I want a rich-client interface.
Okay, enough theory. Let's look at how we can put this all together.
Much like a single-page app, my apps now also have a single server-side rendered root template. The purpose of this file is to include the dependencies (in the head), and to also pass data from my controllers to the Vue components.
<html>
<head>
// ...
</head>
<body>
<div id="app" data-component="{{ $name }}" data-props="{{ json_encode($data) }}"></div>
</body>
</html>
The #app
div serves as our root Vue component element. There is nothing fancy about the two data attributes. They simply provide a way for us to get data from our controller to our Vue components.
Next, let's look at our base JavaScript file, which takes care of booting our Vue components.
import Vue from 'vue'
// Register all the Vue components
const files = require.context('./', true, /\.vue$/i)
files.keys().map(key => Vue.component(key.split('/').pop().split('.')[0], files(key).default))
// Boot the current Vue component
const root = document.getElementById('app')
window.vue = new Vue({
render: h => h(
Vue.component(root.dataset.component), {
props: JSON.parse(root.dataset.props)
}
)
}).$mount(root)
Okay, this might be a little confusing. Let's take it piece by piece.
First, we import Vue. That's easy.
import Vue from 'vue'
Next, we register all our Vue components. This is just a handy shortcut that saves you from having to manually register each component. You can find this in the default Laravel project.
// Register all the Vue components
const files = require.context('./', true, /\.vue$/i)
files.keys().map(key => Vue.component(key.split('/').pop().split('.')[0], files(key).default))
Finally, we boot the current page's Vue component. We do this by getting the current component name from our root div, as well as all the data (props) for that component. Then we create a new Vue instance with a custom render function. By doing it this way, we can actually omit the Vue compiler entirely from our app and only use the Vue runtime. This saves us a few kilobytes in our final vendor.js
file.
// Boot the current Vue component
const root = document.getElementById('app')
window.vue = new Vue({
render: h => h(
Vue.component(root.dataset.component), {
props: JSON.parse(root.dataset.props)
}
)
}).$mount(root)
If you're a Laravel Mix user and wondering how to use only the Vue runtime, make the following change to your webpack.mix.js
file:
let mix = require('laravel-mix')
mix.js('resources/js/app.js', 'public/js').webpackConfig({
resolve: {
alias: { 'vue$': 'vue/dist/vue.runtime.esm.js' }
}
})
Next, we'll add a simple view component
macro. This is the macro that we'll use in our controllers. It's responsible displaying our root template and passing it the prop data.
use Illuminate\Support\Facades\View;
use Illuminate\View\Factory as ViewFactory;
ViewFactory::macro('component', function ($name, $data = []) {
return View::make('app', ['name' => $name, 'data' => $data]);
});
Note that the app.blade.php
view referenced in this macro is our root template from above.
Let's look at how you'd use this macro in an actual controller. Basically whenever you would normally return View::make()
, we will now return View::component()
instead, providing a component name and prop data.
class EventsController extends Controller
{
public function show(Event $event)
{
return View::component('Event', [
'event' => $event->only('id', 'title', 'start_date', 'description'),
]);
}
}
Just keep in mind that all data returned from the controller will outputted as JSON to be made available to your Vue component (and the end user). Meaning, be careful to only return the data you actually need.
This technique also makes testing controllers extremely easy. Instead of making assertions against the rendered HTML generated by a server-side template, you can just test the "component-ready data" returned by the controller.
Let's look at a simplified CreateEvent
component. The categories
prop would be sent from the controller, as we see in our macro example above.
<template>
<layout title="Create Event">
<h1>Create Event</h1>
<form @submit.prevent="submit">
<input type="text" v-model="title">
<input type="date" v-model="date">
<textarea v-model="description"></textarea>
<select v-model="category_id">
<option v-for="category in categories" :option="category.id">{{ category.name }}</option>
</select>
<button type="submit">Create</button>
</form>
</layout>
</template>
<script>
export default {
props: ['categories'],
data() {
return {
title: null,
date: null,
description: null,
category_id: null,
}
},
methods: {
submit() {
// send request
},
}
}
</script>
Note how the page component uses a <layout>
component, and how it even passes a title prop to it. This is a great way to share page layouts across numerous page components. And if you don't need the layout at all, such as on a login page, just omit it! Here's an example layout component. Notice how we're using the default slot (<slot></slot>
) to display our page component within our layout component.
<template>
<div>
<header>
<img src="logo.png">
</header>
<nav>
<a href="/">Dashboard</a>
<a href="/users">Users</a>
<a href="/events">Events</a>
<a href="/events">Profile</a>
<a href="/logout">Logout</a>
</nav>
<main>
<slot></slot>
</main>
</div>
</template>
As noted above, this whole approach still uses classic server-side routing. Which means full page loads on every click. Generally speaking, this is totally fine. However, if you are interested in the snappiness of a single-page app, one really interesting option is to use Turbolinks, a library developed by the fine folks at Basecamp. Turbolinks essentially watches for navigation events (clicking a link), then automatically fetches the page, swaps in its <body>
, and merges its <head>
, all without incurring the cost of a full page load.
Implementing Turbolinks is actually very straight forward. Start by including the library:
npm install --save turbolinks
Next, you'll need to update your base JavaScript file. Replace the Vue component booting in the base JavaScript file with the following:
// Start Turbolinks
require('turbolinks').start()
// Boot the current Vue component
document.addEventListener('turbolinks:load', (event) => {
const root = document.getElementById('app')
if (window.vue) {
window.vue.$destroy(true)
}
window.vue = new Vue({
render: h => h(
Vue.component(root.dataset.component), {
props: JSON.parse(root.dataset.props)
}
)
}).$mount(root)
})
All we're doing here is instantiating our Vue component on each Turbolinks page load event. We also make sure to destroy the previous instance to prevent memory issues.
Finally, disable the Turbolinks caching by adding the following meta tag to your site <head>
. If you don't disable the Turbolinks cache, the Vue components won't be instantiated when users go back (or forward) in their browser history.
<meta name="turbolinks-cache-control" content="no-cache">
That's all there is to it! I'm honestly blown away by how snappy Turbolinks makes my apps feel.
To help illustrate how all this code fits together, I've put together a complete example project based on the default Laravel project.
Maybe some of you have been thinking about the downsides of using client-side rendering. While I think these are already well known in the industry, it's probably worth underlining them in this article, since I am making a strong comparison to server-side rendering.
The first caveat is that your JavaScript filesize is going to be much larger than in a classic server-side rendered app. For example, in my situation, all my Vue components must be bundled and shipped as a JavaScript asset. I've minimized this issue using code-splitting, and of course proper minification and compression. Practically speaking this has not been an issue for my projects, but your mileage may vary.
Second, as with all client-side rendered content, search engines will have a harder time indexing that information. Again, this hasn't been an issue for me, since I tend to only use this technique in web apps. Be sure to consider this if you're building a public facing website.
So there you have it: a practical, elegant approach to doing full client-side rendering in a server-side app. This approach provides all the benefits of having rich client-side rendered templates, while still maintaining classic server-side routing and controllers. Plus, adding Turbolinks to the mix honestly makes these apps feel as snappy as a full-on single-page app.
Of course this approach won't be right in all situations. There are times when a full client-side app and corresponding API will make more sense. There will also be times when a totally classic server-side rendered app makes more sense. Either way, I believe this approach has a lot of benefits, and is certainly worth considering when building a modern web app.
If you have any questions or feedback on this article, send me (@reinink) a message on Twitter. I'd love to hear from you.