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

Colin McDonnell @colinhacks

updated March 7th, 2023

Update 2023 β€” Updated for Next.js 13, React Router 6, and React 18.

Update 2022 β€” Next.js 13 now supports nested routes via the /app directory. However, it requires a server to render React Server Components, and thus doesn't qualify as a "single-page application" as defined in this post.

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, Routes, 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>

        <Routes>
          <Route path="/about" element={<h1>About</h1>} />
          <Route path="/" element={<h1>Home</h1>} />
        </Routes>
      </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). Let's try a naive approach to fixing this problem. If window isn't defined, return null; otherwise, render the page.

import {AppProps} from 'next/app';
import {useEffect, useState} from 'react';

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

Hydration failed

Reload the page and you'll see a new error.

Hydration failed

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 throws an error.

Let's get a little more clever.

import {AppProps} from 'next/app';
import {useEffect, useState} from 'react';

function App({Component, pageProps}: AppProps) {
  const [render, setRender] = useState(false);
  useEffect(() => setRender(true), []);
  return render ? <Component {...pageProps} /> : null;
}
export default App;

This approach useState and useEffect. When Next.js renders a page on the server, it doesn't execute any useEffect calls; it just renders the page using default values and returns the result.

When this page is loaded by the browser will do the same thing: render the page using default values. Because the default value of render is false, the browser will initially render null. This is great, because it agrees with the server-rendered version of the page.

Immediately after, the browser will execute the useEffect call, which will set render to true. The page will now re-render in its full glory. We do waste a little time rendering null initially, but it's a microscopic amount of time: under a millisecond.

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 siezes 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!

This feature was made available in Next.js 9.5. 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!

Mixing client- and server-side rendering

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

updated March 7th, 2023


Subscribe


Hacker News TL;DR Podcast

Colin is a YC-backed founder and open-source developer. Kenny is a human-computer interaction whiz at Apple. Two best friends report daily from the depths of Hacker News.