WordPress on Steroids (node.js)

Jens Nilsson on

This article is about separating WordPress from the frontend responsibility, getting rid of the suboptimal hairballs of PHP mixed with HTML that is WordPress-templates and letting Node.js handle rendering and caching while still letting WordPress do what it's good at, being the admin interface.

The setup

The setup uses Nginx, Apache, Node.js and WordPress to be able to have complete control of how a request flows through all the different layers.

Nginx

Nginx job in this setup is to act as the entry-point for all requests and a reverse-proxy that decides if a request should be handled by Node.js, the WordPress-installation or in the case of images, go straight to the hard drive.

server
{
    listen 80;
    server_name YOURDOMAIN.com;

    # proxy to the node app by default
    location /
    {
        proxy_pass_header Server;
        proxy_buffering off;
        proxy_pass http://localhost:8081;
    }

    # proxy wp-admin to WordPress
    location /wp-admin
    {
        proxy_pass http://YOURDOMAIN.com:81;
    }

    # proxy wp-content to WordPress
    location /wp-content
    {
        proxy_pass http://YOURDOMAIN.com:81;
    }

    # proxy wp-includes to WordPress
    location /wp-includes
    {
        proxy_pass http://YOURDOMAIN.com:81;
    }

    # proxy wp-login to WordPress
    location /wp-login.php
    {
        proxy_pass http://YOURDOMAIN.com:81;
    }

    # for static resources (media) handled by WordPress, go straight to the HD
    location ~* ^/wp-content.+\.(?:jpg|jpeg|gif|png|svg|svgz|mp4|ogg|ogv|webm|htc)$
    {
        access_log off;
        expires 1M;
        add_header Cache-Control "public";
        root /path/to/your/wordpress;
    }
}

Also worthy to mention is that you should put YOURDOMAIN.com in your hosts file and point it to 127.0.0.1 so that requests proxied by nginx to the WordPress-installation doesn't leave the server. This is a minor note though as we'll be caching results in the frontend-server and a request for data only will happen once every time a cache-miss occurs.

Apache

Apaches job is basically to host our WordPress-installation on port 81 so this is just a basic configuration for a WordPress-site except for the port-number.

<VirtualHost *:81>
    DocumentRoot "/path/to/your/wordpress"
    ServerName YOURDOMAIN.com
    ServerAlias www.YOURDOMAIN.com

    <Directory "/path/to/your/wordpress">
        Options FollowSymLinks
        AllowOverride All
        Order Allow,Deny
        Allow from all
    </Directory>

    ErrorLog "/var/log/apache2/YOURDOMAIN.com-error.log"
    CustomLog "/var/log/apache2/YOURDOMAIN.com-access.log" common
</VirtualHost>

WordPress

WordPress job is to do what it's good at, provide it's admin UI for easy managing of data. And to serve that data formatted as JSON via a theme that is made specifically for this purpose. Together with a plugin like Advanced Custom Fields with well defined post-types and custom field definitions your templates will be dead simple and well structured.

WordPress isn't by any means a requirement, it could be replaced with just about any content management software, as long as it can output clean JSON it will work. And that is part of the beauty of this setup, the frontend doesn't care about what system serves up the data it requests.

On the up side

Only outputting JSON from WordPress means that we no longer have to mix php with HTML, big win, the templates will only have to know how to fetch the relevant data and structure it.

On the down side

Since WordPress purpose is to output it's data as JSON, this unfortunately puts a big fat X over all plugins that hooks into the rendering-process to output HTML. So bye-bye to twitter-widgets and contact forms.

Templates

The WordPress-templates will be quite simple, since we only are going to output JSON we only have to build a well-structured object using the data. You can still have all your different templates for different types of pages and posts, just make sure to specify a template name or some other kind of identifier in the data that gets outputted so that the frontend server can identify it.

Below is an example of a simple index.php file that will fetch the latest posts and put them into an array named posts alongside the template-property that will tell the frontend that this is the index-template. Another template might show only a single post and post would be a suiting value for the template property.

<?php

    $public_data = new stdClass();
    $public_data->template = 'index';
    $public_data->posts = array();

    if (have_posts()) {
        while ( have_posts() ) {
            the_post();
            $public_post = $post;
            array_push($public_data->posts, $public_post);
        }
    }

    header("content-type: application/json");
    echo json_encode($public_data);

Node.js

Using Node.js as a frontend server we can choose to use whichever template engine we prefer, again, epic win!

server.js

In the below example an express-server is set up to listen for requests coming in on port 8081. The express-http-proxy middleware forwards the requests to the internal url to get JSON-formatted data from the WordPress-installation.
When the data arrives back in the intercept callback we first replace all occurrences of the internal url with the external url and then parse it to get an object to work with.
The data is then rendered together with the specified template, cached for for future requests and sent off to the client.

var express = require('express');
var jade = require('jade');
var marked = require('marked');
var path = require('path');
var proxy = require('express-http-proxy');
var url = require('url');
var LRU = require("lru-cache");
var compression = require('compression');
var router = express.Router();

var cache = LRU({
    max: 50,
    maxAge: 1000 * 60 * 60
});

var app = express();
var config = require('../config.js');

// gzip
app.use(compression());

// Static content. Example: '/static/css/style.css'
app.use('/static', express.static(path.join(__dirname, '../public')));

// expose utilities for jade
app.use(function(req, res, next) {
    res.locals.marked = marked;
    next();
});

// expose a route that will clear the cache
router.get('/clear-cache', function(req, res, next) {
    if( config.cache ) {
        cache.reset();
    }

    res.json({success: true});
});

app.use(router);

// setup proxy-middleware, rendering and caching
app.use(proxy(config.proxyUrl.protocol + '://' + config.proxyUrl.url, {
    filter: function(req, res) {
        if( config.cache ) {
            var cacheKey = url.parse(req.url).path;
            var hasCache = cache.has(cacheKey);

            if( hasCache ) {
                // cache hit, render and don't proxy the request
                res.set('Content-Type', 'text/html');
                res.send(cache.get(cacheKey));
                return false;
            }
        }

        return true;
    },
    forwardPath: function(req, res) {
        return url.parse(req.url).path;
    },
    intercept: function(data, req, res, callback) {
        var origData = data;
        data = data.toString('utf8');

        // replace all occurences of the internal url with the public url
        var re = new RegExp(config.proxyUrl.url, 'g');
        data = data.replace(re, config.publicUrl.url).replace(/\[proxyurl\]/g, config.proxyUrl.url);

        data = JSON.parse(data);

        // only try to render data that has a template
        if( data.template ) {
            res.render(data.template, {data: data}, function(err, html) {
                if (err) {
                    res.status(500).send(JSON.stringify(err));
                }
                else {
                    if( config.cache ) {
                        // cache the resulting HTML using the path as key
                        cache.set(url.parse(req.url).path, html);
                    }

                    // send the response to the client
                    res.set('Content-Type', 'text/html');
                    res.send(html);
                }

                // tell express-http-proxy that we already sent the response
                callback(null, origData, true);
            });
        }
        else {
            res.status(404);
            callback(null, origData);
        }
    },
    decorateRequest: function(req) {
        return req;
    }
}));

app.engine('jade', jade.__express);
app.set('view engine', 'jade');

app.listen(config.port);

Modules to note

  • express - To handle middleware, our only route and rendering together with a template-engine.
  • jade - A template engine amongst others. My choice for this specific site.
  • marked - To be able to parse markdown.
  • express-http-proxy - This is where most of the action happens. Proxy incoming requests, intercept and render on the way back.
  • lru-cache - To handle caching of requests.

Request life-cycles

Some step-by-step examples on how a request flows through the different layers of the setup.

GET http://jensnilsson.nu/wordpress-on-steroids-node-js (not cached)

  • Request comes in on port 80 (nginx)
  • It's not a request to /wp-admin, /wp-includes, /wp-content or /wp-login.php so nginx proxies the request to node.js (port 8081)
  • Node.js checks if the key, wordpress-on-steroids-node-js, exists in the in-memory cache, it doesn't so it forwards the request to the "internal" url, WordPress running on port 81.
  • WordPress gets the request for the page and the usual WordPress page-load happens, ultimately resulting in a JSON-formatted response.
  • Node.js recieves the data and renders it together with a template resulting in a complete HTML-page.
  • The finished HTML is cached in-memory using the requests path as the key.
  • The HTML is sent back to the client.

GET http://jensnilsson.nu/wordpress-on-steroids-node-js (cached)

  • Request comes in on port 80 (nginx).
  • It's not a request to /wp-admin, /wp-includes, /wp-content or /wp-login.php so nginx proxies the request to node.js (port 8081)
  • Node.js checks if the key, wordpress-on-steroids-node-js, exists in the in-memory cache and it is instantly retrieved and sent back to the client.

GET http://jensnilsson.nu:81/wordpress-on-steroids-node-js

  • Request comes in on port 81 (apache).
  • WordPress gets the request for the page and the usual WordPress page-load happen, ultimately resulting in a JSON-formatted response.
  • Check out the response for this post.

GET http://jensnilsson.nu/wp-content/uploads/2015/03/image.jpg

  • Request comes in on port 80 (nginx).
  • The request is for /wp-content and it ends in .jpg so nginx goes straight to the HD to pick up the requested file and tells the browser to cache it for a month.

Conclusion / TL;DR

Putting Node.js in front of a WordPress-installation that only outputs it's data as JSON gives us the best benefits from both. WordPress brings it's easy-to-use admin UI where we manage and store our data while Node.js brings template engines, caching and full control of the request flow.

The decoupling of data from the layout also makes the parts easy to replace separately. You want to have a different CMS? Just make sure it returns the same structure of JSON-data as the old one. I know, changing your CMS and keeping the frontend exactly as it was doesn't happen very often but that might change.
A new frontend? No problem, just have a peek at the well-formatted data that is already at your disposal and start building it using your favourite template-engine.

This site is built using this technique and can of course be found on github.