Here at OddBird, we’re lucky enough to mostly work on greenfield
projects – which means we choose our own tech stack. One of the first
questions is how to render templates for the initial page-load. There
are many reasons to prefer server-side rendering over a “pure”
single-page app which always renders content in the browser – it’s
better for SEO, users don’t have to wait for the JavaScript to
initialize before seeing content on the page, etc. But it’s also more
work to convince a client-side MV* framework to play nicely – and
efficiently – with server-rendered markup.
Kit has already laid out some of the options for sharing templates
between client and server, and outlined one way we’ve tried to reduce
code/logic duplication in the API layer. That’s a great start, but we
still need to turn that server-rendered markup into an interactive
single-page application.
Getting the Data
There are a few ways we could transfer data from the server to our
client-side application:
- Request JSON from the API via an XHR
- Embed JSON in a
<script>
tag (either type="text/javascript"
or
type="application/json"
)
- Embed JSON in data-attributes on DOM elements corresponding to
models or collections
The first option is the “cleanest” (allowing the JS to consistently
fetch data through the API), but adds an unnecessary XHR and wait-time
before the page is ready for user interaction.
The second and third options are similar. Using a <script>
tag is
probably the most efficient (using only one DOM interaction to acquire
the entire data set), but requires careful namespacing and patterns to
know which data should be attached to which existing markup. In cases
involving large collections, this is my preferred approach.
Storing JSON in data-attributes on individual DOM elements has the
advantage of coupling the data and markup together for each component,
but requires consistent markup patterns if the JS is to be reusable for
various pieces of the app. It necessitates DOM interactions to fetch the
data, which could easily cause performance issues with larger
collections. In our case – with a relatively small data set for each
page – this option provides both reasonable performance and a clear
relationship between the data and its corresponding markup.
For example, let’s say that we want to attach models and views to a
server-rendered list of comments. Using Jinja2/Nunjucks, our markup
might look like this:
<div class="comment-list">
<article class="comment" data-js-model="{{ comment | json }}">
<p>{{ comment.body }}</p>
<p>{{ comment.author }}</p>
</article>
</div>
Brief <aside>
You’ll note that we’re using a custom json
filter to convert an object
into a JSON string. One downside of sharing templates between front-end
and back-end (Nunjucks written in JS on the front-end, and Jinja2
written in Python on the back-end) is that any custom filters used in
shared templates must be written in both languages. So for this to work,
we have a json
filter added to our Nunjucks environment:
import nunjucks from 'nunjucks';
const env = new nunjucks.Environment();
env.addFilter('json', (val) => JSON.stringify(val));
And a corresponding filter added to our Jinja2 environment:
from json import dumps
from jinja2 import Environment
def environment(**options):
env = Environment(**options)
env.filters.update({
'json': json,
})
return env
def json(val):
"""Return given value as a JSON string."""
return dumps(val)
This isn’t ideal, but seems like a reasonable trade-off since it allows
us to avoid duplicating all the template files themselves.
Ok, </aside>
.
Using the Data
So we’ve made the model/collection data available in the DOM without
requiring an additional XHR. Now we need to add our JS layer, turning
the data into actual models or collections that are managed by views.
The details differ here from one framework to another. Since we’re using
Backbone.js and Marionette (^3.0.0), let’s look at one approach with
those frameworks.
import BB from 'backbone';
import Mnt from 'backbone.marionette';
const ViewWithModel = Mnt.View.extend({
initialize () {
if (this.options.el) {
this.attachModel();
}
},
attachModel () {
const child = this.$('[data-js-model]');
const modelData = child.data('js-model');
this.model = new BB.Model(modelData);
this.triggerMethod('render', this);
}
});
const myView = new ViewWithModel({ el: $('.comment') });
Or for a view with a collection of models:
import BB from 'backbone';
import Mnt from 'backbone.marionette';
const MyChildView = Mnt.View.extend({
});
const ViewWithCollection = Mnt.CollectionView.extend({
collection: new BB.Collection(),
childView: MyChildView,
initialize () {
if (this.options.el) {
this.attachChildren();
}
},
attachChildren () {
const view = this;
const collection = view.collection;
const children = this.$('[data-js-model]');
children.each((idx, el) => {
const $el = $(el);
const modelData = $el.data('js-model');
let model = collection.get(modelData.id);
if (!model) {
model = collection.add(modelData, { silent: true });
}
const childView = new view.childView({ model, el });
view.addChildView(childView, idx);
});
view._isRendered = true;
view.triggerMethod('render', view);
}
});
const myView = new ViewWithCollection({ el: $('.comment-list') });
Now we have a model (or collection of models) instantiated with data
from our server-rendered markup, all being managed by Marionette views!
🎉
Where Do We Go From Here?
In the end, we’re moving toward the best of both worlds: a
server-rendered page (easily indexable by search engines, with content
immediately visible to users), with the client-side benefits of a
single-page app (live-updating components, and no page refreshes).
There are a number of improvements we could make – prioritizing the most
important pieces of interactivity and lazy-loading the rest, abstracting
our code into a Marionette behavior that can be added to any view
where we want to pre-load with data from the DOM – but this is a good
start. Every step of the way, we strive to minimize the amount of
duplicated code or logic – no need for a JavaScript process on the
server, and no duplicated templates.
We have a number of other tricks for sharing canonical data – global
settings, third-party API keys, minified asset mappings, and even color
maps generated directly from SCSS – but those will wait for a later
installment in this series.
How have you tackled the problem of wiring up a single-page application
with server-side rendering? What are we missing, or where could we
improve our methods? Drop us a line via Twitter!