Ozzie Neher

Developing for the Web

Turning Part of an Existing Application into a Single Page App

October 9th, 2016

Single Page Applications (SPA's) are great. They keep a fluent flow for your website and, with some creativity, are capable of some pretty nifty things. What most people see the downside to SPA's is that they most likely don't want their whole application to be a single page. Maybe they just want a section of their website (e.g a dashboard) to be a single page, while the rest of the application is still all handled by the server. I've found this to be true when adding a frontend framework to an existing application. Here's how I did it!

TL;DR / Why this works

If you don't want to read the Laravel/React specific code, here's the general concept:

You need whatever you're using for your applications backend (e.g Laravel) to be listening on a route with an optional parameter (e.g /dashboard/{slug?}). Have the controller point whatever matches that route to a single template file that loads our JavaScript with our JavaScript router.

If your SPA is not going to sit on the root, the JavaScript router (e.g React Router) then needs to be set with a baseurl-like property that tells the client-side router that we're working from within a server given route.

Then it takes whatever the URL is and matches it against its own URL's that we define in our Javascript.

So, using the above example:

A user requests /dashboard/pages/new. This is going to hit our backend with a slug of pages/new. Our backend doesn't care about the slug, it just cares that we matched the /dashboard/{slug?} pattern, and is going to serve that single file we told it to.

Then, our client-side router will be loaded and will check the URL. If the URL matches a defined pattern in the client-side router then it will display the correct component.

Preface

This specific approach is going to use React (v15) + React Router (v4) and Laravel (5.3). The same principles, however, can be applied across other languages and frameworks.

Routes

Let's take a real-world example. I was building an application where users could have specific dashboards and I wanted to use React only for this part of the site, not the rest. Let's take a look at the routes.

<?php
// routes/web.php

// Define all of your applications other routes like normal
// e.g
Route::get('about', 'PageController@about');
Route::get('contact', 'PageController@contact');

// Then for the SPA route, have this catch all slug (Route group not necessary)
Route::group(['prefix' => '/dashboard/{id}'], function() {
    Route::get('{slug?}', 'DashboardController@site')->where('slug', '.*');
    // ^^^ this is the magic!
});

So here I'm just defining a route group that will handle all routes for a specific dashboard. Inside this group, we have a catch all slug. What this does, is hit anything matching /dashboard/{id}/. So for example, /dashboard/1/pages would match, and /dashboard/4/pages/4/edit/update would match. Let's have a peek inside DashboardController@site.

<?php
// DashboardController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Http\Requests;
use App\Site;

class DashboardController extends Controller
{
    private $sites;
    public function __construct(Site $sites)
    {
        $this->sites = $sites;
    }
    public function site($id, $slug = '')
    {
        $site = $this->sites->whereId($id)->firstOrFail();
        return view('dashboard.site', ['site' => $site]);
    }
}

So as you can see here, the site function takes an $id parameter (via the route group, only if yours is setup like mine), and a $slug which takes a default value since it is an optional parameter (i.e someone hitting just /dashboard/1/ has no slug whereas /dashboard/1/edit edit is the slug. With this controller function, I can grab the sites dashboard info with Laravel (via $id) and pass it along to a view for react router to handle.

An important thing to note is that the $slug parameter, since it is a catch-all, is going to contain the entire slug. So if the route is /dashboard/1/pages/4/edit, the $id would be 1 and the $slug would be pages/4/edit, so it is important if you are doing anything with the slug to keep that in mind. A good thing to do might be to put $slug = explode('/', $slug) at the top of your controller function to separate the slug into individual parameters.

Also keep in mind that we likely won't be doing anything with the $slug in Laravel, as our react router will handle this.

<!-- dashboard.site view -->
<html>
<head>
    <script>
        window.THE_SITE = <?php echo json_encode($site); ?>;
    </script>
</head>
<body>
    <div class="container">
        <div id="siteDashboard"></div>
    </div>
</body>
</html>

In the blade view, we can see that we're setting a global JavaScript variable called THE_SITE, where I store the model being passed from the view for our react component to interact with.

// app.js

import React, { Component } from 'react'
import { render } from 'react-dom'
import { BrowserRouter, Match, Miss } from 'react-router'
import Overview from './Overview'
import NewPage from './NewPage'
import NotFound from './NotFound'

export default class SiteDashboard extends Component {
    constructor(props) {
        super(props)
    }

    render() {
        return (
            <BrowserRouter basename={`/dashboard/${THE_SITE.id}`} >
                <div>
                    <Match exactly pattern="/" component={Overview} />
                    <Match exactly pattern="/new" component={NewPage} />
                    <Match exactly pattern="/pages/:id" component={EditPage} />
                    <Miss component={NotFound} />
                </div>
            </BrowserRouter>
        )
    } 
}

render(<SiteDashboard />, document.getElementById('#siteDashboard'))

And then here is the JS code that handles the routing. It's very important that your <BrowserRouter> has a basename property if your route that you're serving via Laravel is not on /. Laravel is setup to handle /dashboard/{id}/ so that is what the base is for the React app to handle the rest.

So from this, if you were to hit /dashboard/1/ you would see the component <Overview>, if you were to hit /dashboard/1/new or /dashboard/1/pages/4 you would see the <NewPage> or <EditPage> respectively. If you hit a route that isn't defined in react router such as /dashboard/1/loldoesntexist you would see the <NotFound> component.