Building a single-page application with Next.js and React Router

Colin McDonnell @colinhacks

published October 28th, 2020

A previous version of this post contained a discussion regarding Vercel (the company that maintains Next.js), perverse incentives, the blurring of the static-dynamic dichotomy, and the future of the SPA paradigm. Those thoughts have been moved to a separate post here! Vercel, Next.js, and the war on SPAs.

I recently set out to implement a single-page application (SPA) on top of Next.js.

To clarify, I'm using the term "SPA" in a very specific way. I'm referring to the "old school" SPA model: an application that handles all data fetching, rendering, and routing entirely client-side.

Turns out, Next.js does not make this easy.

Why not use Next.js routing?

Next.js is not as flexible as React Router! React Router lets you nest routers hierarchically in a flexible way. It's easy for any "parent" router to share data with all of its "child" routes. This is true for both top-level routes (e.g. /about and /team) and nested routes (e.g. /settings/team and /settings/user).

This isn't possible with Next's built-in router! Instead, all shared state and layout must be initialized in your custom _app.tsx component. It's equivalent to building an app with a single, top-level React Router. This is true despite the fact that Next.js nominally lets you define "nested routes" (e.g. pages/settings/team.tsx). In practice it "flattens" all those routes.

There are complex, hacky ways of achieving this in Next, including several described here by Adam Wathan. If you are dead-set on using Next's routing system, you should look at those approaches.

Why use Next.js at all?

Because it's awesome! You can split your bundle with dynamic imports, which solves one of the biggest performance issues associated with large SPAs. You can easily deploy to Vercel. You can build your API using API Routes, instead of maintaining separate client and server codebases. Plus there's that snazzy new <Image> component!

But the killer feature of Next.js for me is the zero-config client-server codesharing.

The ability to share code and typings between my client and server is a massive win. The developer experience is 10x better than Lerna and 100x better than Yarn Workspaces (trust me). It's the enabling feature behind my new library github.com/colinhacks/trpc, which lets you build an end-to-end typesafe data layer. Better yet, it abstracts away the API entirely and lets you call your server-side functions directly from your client. I'm biased, but it's extremely cool. Check it out at github.com/colinhacks/trpc! ✨

Implementation

For highly interactive "dashboard-style" SaaS development, the dominant paradigm is still the SPA. Unfortunately Next.js is (nearly) incompatible with the SPA paradigm for one fundamental reason: routing. Next.js ships with its own built-in page-based routing system, whereas the typical SPA relies on client-side routing, typically using a library like react-router.

Below I will walk through all the errors I encountered while trying to use React Router inside a Next app and how I fixed them. For demonstration purposes, I'll be using the simple router below, which is a simplified version of the "Basic Routing" example from the react-router docs.

import React from 'react';
import { BrowserRouter as Router, Switch, Route, Link } from 'react-router-dom';

export default function App() {
  return (
    <Router>
      <div>
        <ul>
          <li>
            <Link to="/">Home</Link>
          </li>
          <li>
            <Link to="/about">About</Link>
          </li>
        </ul>

        <Switch>
          <Route path="/about">
            <h2>About</h2>
          </Route>
          <Route path="/">
            <h2>Home</h2>
          </Route>
        </Switch>
      </div>
    </Router>
  );
}

Browser history needs a DOM

Let's start by creating a new Next.js app, installing React Router (npm install react-router-dom), and pasting the router code above into pages/index.tsx. When you run next dev you'll immediately get a not-so-subtle Invariant failed: Browser history needs a DOM error.

Browser history needs a DOM

This happens because Next.js tries to pre-render your pages on the development server in development mode. React Router, on the other hand, requires access to the global window object provided by the browser. Because window isn't available in the server environment, React Router craps out.

To fix this, we need to (drumroll please) use a custom App! Add a file called pages/_app.tsx (or pages/_app.js if you're a monster) and add the following content:

import { AppProps } from 'next/app';

function App({ Component, pageProps }: AppProps) {
  return (
    <div>
      {typeof window === 'undefined' ? null : <Component {...pageProps} />}
    </div>
  );
}

export default App;

Now your app won't render anything unless the window object is defined; instead the server just returns null. This is a hack to prevent Next.js from trying to render anything on the server.

Expected server HTML to contain a matching div

Now that we've got React Router working, try loading a page. If you open the Console, you'll likely see a warning like the below:

Nextjs Hydration

When you load a page of your Next.js app, Next.js 1) tries to pre-render it on the server, 2) sends the result to the browser, and 3) "re-hydrates" the page in the browser. Re-hydration means the page is re-rendered again on the browser and compared against the version that was rendered on the server. If they disagree, React issues a non-critical warning. This shouldn't actually cause any problems, but it can be annoying.

Fortunately it's an easy fix. In your custom App, just add the suppressHydrationWarning attribute to the <div>:

import { AppProps } from 'next/app';

function App({ Component, pageProps }: AppProps) {
  return (
    <div suppressHydrationWarning> // <- ADD THIS
      {typeof window === 'undefined' ? null : <Component {...pageProps} />}
    </div>
  );
}

export default App;

This is a built-in attribute provided by React for solving this exact problem.

404 Page Not Found

React Router and the Next.js router can interact in strange and unexpected ways. To understand the problem, you need to understand the different between client-side routing and "server-side routing".

Currently pages/index.tsx implements a router with 2 "pages": the home page (/) and an about page (/about).

Try this:

  1. Go to localhost:3000
  2. Click the "About" link

You should see the URL changes to localhost:3000/about and we see the About "page". Great! But:

  1. Refresh the page

Now we get a 404. What gives? localhost:3000/about worked just fine before the reload.

As far as Next.js is concerned, the homepage (localhost:3000) is the only page that exists. Once that page loads, react-router takes control of the URL bar. When you click a react-router <Link>, it changes the URL programmatically, but the page is never fully reloaded.

But when you refresh the page, the browser asks Next.js to provide the content for localhost:3000/about. Since we haven't implemented pages/about.tsx, Next.js gives up and throws a 404.

The solution: Rewrites!

The solution was recently made available with the release of Next.js 9.5: rewrites. Rewrites are like "URL aliases"; you can tell Next.js to remap some "source" URL to a different "destination" URL. You can configure them in next.config.js file (if that file doesn't exist you should create it in the root of your project). Add the following contents:

// next.config.js
module.exports = {
  async rewrites() {
    return [
      {
        source: '/:any*',
        destination: '/',
      },
    ];
  },
};

This rewrite rule maps all incoming requests (/:any*) to the homepage (/).

Now, if you restart the server and navigate to localhost:3000/about your index.tsx homepage should render. Yay!

Introducing the "hybrid SPA"

Important note: Next.js ignores this rewrite rule if the incoming request corresponds to an existing page in the pages directory. For instance, if you add pages/about.tsx later, Next.js will stop rewriting /about to the homepage.

Nextjs rewrite documentation

This is actually really cool! For starters, it lets you use API Routes without any additional configuration. But more importantly, it lets you mix-and-match the SPA, SSR, and SSG paradigms at will! For instance, to add a /settings page that is server-side rendered:

  1. Create and implement pages/settings.tsx
  2. Add a getServerSideProps fetcher
  3. Add a link to /settings using the <Link> component from next/link (not the one from react-router-dom!)

Now you have a single server-side rendered page inside your SPA! Can't do that with create-react-app 🙃

Wrap up

Big shout out to Tanner Linsley's GitHub Gist on converting from CRA to Next.js, which inspired and informed this post.

You can see ALL the code I used here in the demo repo at github.com/colinhacks/nextjs-spa.

The "comments section" for this post is this Twitter thread. Chime in! I love discussing Next.js, TypeScript, open-source software, and just about everything else!

If you're into Next.js or TypeScript, follow me on Twitter @colinhacks! I build and maintain open-source tools like Zod (a TypeScript validation library) and tRPC (a tool for building end-to-end typesafe APIs with TypeScript). Or subscribe to the newsletter to get notified when I publish new posts!


Colin McDonnell @colinhacks

published October 28th, 2020


1. Personalize your topics

2. Type your email