DEV Community

Alex MacArthur
Alex MacArthur

Posted on • Originally published at macarthur.me on

Mount a Multi-Page SPA into an App with Server-Side Routing

This is one of those posts largely written for later reference by my future self.

I've been building a small React application. It has a few routes orchestrated by React Router, but it needed to be mounted onto a path within a Laravel application that uses traditional server-side routing.

Here's a contrived scenario. Each router – server-side Laravel and client-side React – defines it's own set of routes, with the latter being mounted onto one route of the former. A similar setup could be seen with another router and framework (Vue, Django, whatever). I just happen to be working with Laravel and React.

route diagram

If the React application is always booted up at the base route (in this case, /spa), there's no issue. React Router will completely assume ownership of subsequent navigations entirely on the client. But if the user mades a fresh request to one of these client-side routes directly, Laravel will attempt to resolve it as any other GET request, and it'll fail.

For example, say we have those routes defined like this, each rendering a specific Blade template:

Route::get('/server-rendered-one', function (Request $request) {
    return view('one');
});

Route::get('/server-rendered-two', function (Request $request) {
    return view('two');
});

Route::get('/spa', function (Request $request) {
    return view('spa');
});
Enter fullscreen mode Exit fullscreen mode

Here's what you'll see by navigating to /spa/client-rendered-two:

404 page

That makes sense. That route doesn't exist, so Laravel behaves accordingly.

In order for these breeds of routes to live in harmony, Laravel needs to surrender all direct requests for a route to the same HTML loading the same React application, allowing the client-side router to take over with whatever path is shown in the browser.

Tell React Router Where It'll Live

Here are the routes I've wired up with React Router. One base route with a couple of others:

import React from "react";
import { createRoot } from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router-dom";

const router = createBrowserRouter(
    [
        {
            path: "/",
            element: <>Root page!</>,
        },
        {
            path: "/client-rendered-two",
            element: <>Second client-rendered page!</>,
        },
        {
            path: "/client-rendered-three",
            element: <>Third client-rendered page!</>,
        },
    ]
);

const domNode = document.getElementById("root");
const root = createRoot(domNode);

root.render(<RouterProvider router={router} />);

Enter fullscreen mode Exit fullscreen mode

In local development, navigating to / would expectedly render "Root page!" But in order the router to correctly parse the path while running on a server-rendered /spa path, I need to React Router know about it using the basename option:

const router = createBrowserRouter(
    [
        {
            path: "/",
            element: <>Root page!</>,
        },
        {
            path: "/client-rendered-two",
            element: <>Second client-rendered page!</>,
        },
        {
            path: "/client-rendered-three",
            element: <>Third client-rendered page!</>,
        },
    ],
    {
        // Tell RR this app will always mounted at /spa.
        basename: "/spa", 
    }
);
Enter fullscreen mode Exit fullscreen mode

With that in place, navigating to /spa/client-rendered-two will effectively instruct the router to ignore the base /spa, and to determine which component to rendered using the segments that follow. It'll also automatically prepend that path onto any client-side navigation that follows. So, we're good to go with the client-side piece.

Catch All Path Parameters

In order to send any /spa/* request to the same Blade template, we're gonna have to do some "route globbing," which can be accomplished with Laravel's optional parameters. The trick is to throw a ? after the parameter you'd like to be optional:

Route::get('/spa/{whatevs?}', function (Request $request) {
    return view('spa');
});

Enter fullscreen mode Exit fullscreen mode

That'll work for any two-segment paths, but it won't work if you were to navigate to /spa/taxation/is/theft. To get the route to match any any number of segments, we can use a regular expression constraint:

Route::get('/spa/{whatever?}', function (Request $request) {
    return view('spa');
- });    
+ })->where('whatever', '.*');
Enter fullscreen mode Exit fullscreen mode

This constraint uses the extremely loose .* pattern to tell Laravel that the whatever path segment can match any type of character, even slashes, so we'll be covered if our React app every introduces deeper, more complex routes. Let's reload:

working page

All set. Laravel is ready to accept any request whose path starts with /spa, no matter what the rest of the URL looks like. And when it does, it's React Router's time to shine.

Transferrable to Other Frameworks

Again, there's nothing conceptually here that limits mounting a client-side router into just Laravel. If you were working in Rails, for example, it's actually even simpler using a wildcard segment. Just keep in mind that the parentheses are required – they make the segment optional:

# config/routes.rb

get 'spa/(*whatever)', to: 'spa#index'
Enter fullscreen mode Exit fullscreen mode

And if you were using something like Vue Router, you'd then set the base property for property configuring client-side routing. You get the idea. We're just dealing with JavaScript and HTTP stuff here. The tools are purely tactical.

Why Tho

On the surface, using a pattern like this might feel over-engineered. Maintaining routes for an application can get complex in any single paradigm, and now we're distributing it over two – server and client? In this economy? Nevertheless, I've become more open to it as real-life scenarios have come up.

For one, I think it can be useful for "domain" management within a monolith application. This is roughly the situation around the project that prompted this post. The server can choose which server-side routes to expose (maybe one for each domain/part of the application), and allow the the client to then own the routes within those domains. The entirety of the client-side experience can then be managed somewhat independently from the broader application.

Second, it can be helpful for slowly transitioning a traditional application over to a full client-side architecture. As more and more routing is delegated to the client, the application would manage fewer routes on its own, leaving only those for providing data and handling mutations.

For my own projects, however, I've been preferring a different mash of approaches – full server-side routing with a JS-powered front end that interfaces with those routes in a way that makes the application feel like a full-blown SPA. It's been popularized by Inertia.js the past few years, and I'm using it to run JamComments with immense satisfaction.

At any rate, it's been rewarding to explore these varying models for routing, and I'm eager to see which ones lay the strongest claim for different use cases as time goes on.

—-

If you’d like to be notified whenever I publish a new post, consider signing up for my newsletter.

Top comments (0)