Engineering

Prompting Users to Reload Your Next.js App After an Update

Jake Marsh
April 05, 2021

The pages of a Next.js application are served in one of two ways: server-side rendering, or client-side rendering. It's important to understand the distinction, and when each scenario occurs. (There is also static generation, but we will disregard that for this walkthrough.)

Server-side rendering is when the underlying Node.js server is handling the request, loading the corresponding page component (and any data dependencies), and returning the populated HTML that results. A page will be server-side rendered if it's the initial request to load the page, and the page implements either getInitialProps or getServerSideProps.

Client-side rendering is when the Javascript in the browser has taken over the handling of the request, and React will handle rendering the new page component and reconciling any differences. Client-side rendering occurs when the user has already loaded your application, and is navigating via the Next.js router (either directly) or via the <Link /> component.

The important caveat with client-side rendering is that once the user has loaded the application and each of the pages, requests are no longer being made to the server to render any of them -- the client is handling it all. This means that if you deploy a new version of your application while someone is using it, they could continue seeing and using the previous version of your application until they happen to reload.

This can cause issues if you're making breaking changes, or fixing bugs, or making any other changes you'd prefer your users see ASAP. This risk is multiplied by the number of people using your application. So how can you handle new deploys on the client to ensure your users get the latest version?

Next.js allows customizing the Webpack configuration used at build time via a next.config.js file. It will automatically pass in various relevant arguments; the one that we're interested in is buildId. By default, this is a random string unique to each build.

Combined with Webpack's DefinePlugin, you can expose this buildId to your application by replacing any checks for process.env.BUILD_ID with the real buildId:

// next.config.js

module.exports = {
  ...
  webpack(config, { webpack, buildId }) {
    config.plugins.push(
      new webpack.DefinePlugin({
        'process.env': {
          BUILD_ID: JSON.stringify(buildId),
        },
      }),
    );

    return config;
  },
};

This means that the resulting bundles served on the client will have the real buildId available to them when checking process.env.BUILD_ID. Since these bundles stay loaded as client-side navigation occurs, this will remain a static reference to the buildId loaded on the client.

Next, you'll want to also expose this process.env.BUILD_ID variable in your server-side environment. This is because when you deploy a new version of your application, anything being handled by the server will immediately be operating on the newest version. You can do this via Next.js's API routes:

// pages/api/build-id.ts

import { NextApiRequest, NextApiResponse } from 'next';

export default (_req: NextApiRequest, res: NextApiResponse): void => {
  res.status(200).json({
    buildId: process.env.BUILD_ID,
  });
};

With this new endpoint exposing process.env.BUILD_ID from the server, you have a route you can hit at any time to get the newest deployed buildId: /api/build-id.

Since the client will now have a static reference to its own buildId, and the server now has the endpoint always returning the newest buildId, you can implement your own polling and diffing to determine if the user needs to reload. Below is a component that encapsulates this logic, polling for the latest buildId every 30 seconds via a useInterval hook. This can then be rendered anywhere in your application.

import { useEffect, useRef } from 'react';
import request from 'superagent';

function useInterval<P extends Function>(
  callback: P,
  { interval, lead }: { interval: number; lead?: boolean },
): void {
  const savedCallback = useRef<P>(null);

  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  useEffect(() => {
    const tick = (): void => savedCallback.current();

    lead && tick();

    if (interval !== null) {
      const id = setInterval(tick, interval);

      return () => clearInterval(id);
    }
  }, [interval]);
}

export function DeployRefreshManager(): JSX.Element {
  useInterval(
    async () => {
      const { buildId } = (await request.get('/api/build-id')).body;

      if (buildId && process.env.BUILD_ID && buildId !== process.env.BUILD_ID) {
        // There's a new version deployed that we need to load
      }
    },
    { interval: 30000 },
  );

  return null;
}

At walrus.ai, we display a non-closable modal to the user with just one possible action: reloading the page. Clicking this button simply calls window.location.reload().

Screenshot of walrus.ai's new version modal

This is achieved by simply setting a boolean state value in the above if statement, and conditionally returning your modal element from the component instead of always returning null.

You'll now be able to rely on the fact that your users are always using the latest version of your application, or at least being prevented from taking actions until they've reloaded.

Follow us on Twitter