Guillermo de la Puente

Don’t store app state in the URL with the Next.js router

Development

Published on / Updated on

RSS Feed RSS Feed

The Next.js team built a powerful router component, next/router. I’m not a fan (more on that at the end). It seems they followed the conventions of the already mythical React Router, but more adapted to Next.js and its features. Its API is very easy though, but it encourages relying on it for application state. Through sweat and pain I found again and again that it’s a bad idea.


Simple scenarios are okay

Imagine we have a blog app and we have a page like /blog/[postSlug].tsx for URLs like /blog/love-hate-nextjs

We get the slug like this:

function MyApp() {
  const router = useRouter();
	const postSlug = router.query.postSlug;

  // some hypothetical implementation to get a link to the next post
  const nextPostSlug = useGetNextPostSlug(postSlug);

  return (
    <Layout>
      <BlogPost slug={postSlug} />

      <Button
        onClick={() => router.push(`/${nextPostSlug}`)}
      >
        {`Next post`}
      </Button>
    <Layout>
  )
}

This example is fine. Blog posts are not very dynamic anyway. How frequently is a user expected to change blog post? Not that quickly.


Scenarios with fast changing state and SSR aren’t okay

However, when changing the URL too frequently between routes that have SSR, weird things can start to happen. The route change triggers a request to the server on each change and produces a re-render of the app (which can be expensive performance-wise).

As a real world example, in After Memorials users can swipe through the timeline quite rapidly, and each photo in the timeline has a unique URL associated.

If we updated the URL in each swipe, we’d quickly start seeing errors like these:

Error: Cancel rendering route

Error: Loading initial props cancelled

Also, app performance would slow down during route changes, especially when doing animations.

Solution: keep state in context/Redux and sync it to the URL after

Don’t rely on the router’s query for rendering the application. Instead, keep your state where it should be, inside a useState or Redux.

Then, use an effect to sync the state to the URL with a debounce, so that it will update only after there are no more changes.

function getBrowserPath(params) {
  // your implementation here
}

const URL_UPDATE_DELAY = 750;

function useKeepBrowserPathUpdated(params) {
  const path = getBrowserPath(params);

  useEffect(() => {
    // the timeout adds a small delay to reduce the errors logged
    // by Next when a navigation is interrupted
    const timeout = setTimeout(() => {
      Router.replace(
        path,
        undefined,
        { shallow: true } // shallow: true won't do anything in most cases, but we're optimistic
      );
    }, URL_UPDATE_DELAY);

    return function cleanUp() {
      clearTimeout(timeout);
    };
  }, [path]);
}

Put useKeepBrowserPathUpdated where your application state is.

In any case, I’m not a fan of next/router. For example:

  • Why use router.query for both the URL query params and the dynamic route parameters?

  • Why the result of useRouter can change on renders? It forces us to use the undocumented global export import Router from 'next/router' in order to do Router.replace inside a useEffect hook?

Still, I’m very grateful to all developers for creating Next.js. It’s so good to work with it and it’s exciting to see how it’s evolving (thank you!).

Back to all posts