Managing state is a big part of building a React app. You might already be familiar with managing state in a React single page application (SPA) using state management libraries such as Redux Toolkit or Zustand. But managing state in a Next.js app is quite different.

In this post, I will walk you through how you can manage state in a Next.js app. In most cases, you will not need a state management library for this and I will explain why in this post.

Hand drawn sketch of the Next.js logo and a state management library

The key difference between Next.js and React SPAs

The most important difference to remember when managing state in a Next.js app vs a React SPA is that Next.js is primarily a server-side framework. Next.js apps run on the server* and they use React to render the pages that are requested by a user.

Typically when managing state in React SPAs, we set up global state that is accessible across several components. We also rely on the fact that each user will have a separate instance of the SPA to store information specific to a single instance within the global state.

With Next.js, a single instance of the app on the server is shared across all its users. In this scenario, we can't store user or instance-specific information in a global since different users will keep overriding it. To overcome this limitation, we have to be stateless. Instead of being "stateful" by storing instance-specific values that are shared across several pages, each page of the app can be "stateless" by explicitly accepting all the values it needs to render and not relying on shared values. I will show you how in the rest of this post.

(* Technically, Next.js apps also run within the browser. So with some trickery, you can use global state and state management libraries within Next.js apps. But this is not the best approach. Read on for more info.)

The best state management is no state management

In many cases, you can avoid using state entirely in Next.js apps. In React SPAs, state is often used to manage fetching data from an API. However, Next.js has several data fetching features that would allow you to do this on the server without using state.

Ideally, state should only be used in a Next.js app when you have interactive components that should respond to user input.

Managing different types of state

Not all state is the same. Here are my guidelines for managing state in a Next.js app.

The last guideline might seem strange if you are used to building React SPAs (Single page applications). While you could implement global state across several pages within a Next.js app, it is not the best option for the following reasons.

By using URL parameters and cookies, you can make each page work standalone without relying on data from other parts of the app. This makes them much easier to understand and maintain. You also gain the following benefits.

Switching from global state to URL parameters and cookies takes a bit of getting used to. Let's look at how it works.

Passing values to pages

By using URL parameters and cookies, we are simply passing values to a page component. A page component can then change its behavior based on these values.

// This page component accepts a single parameter or "dynamic segment"
// By checking the parameter value, it can change it behavior
const ProductDetailPage = async ({ params }) => {
const { size } = await params;
if (size === 'big') {
return (
<div>
I am a big product
</div>
)

}
if (size === 'small') {
return (
<div>
I am a small product
</div>
)
}
}

export default ProductDetailPage;

A different page can change the behavior of this page by changing the values that are passed to it. In the case of URL parameters, this means changing the link to the page.

import { useState } from 'react';
import Link from 'next/link'

const ProductListPage = async () => {
const [size, setSize] = useState('small');

const handleSizeChange = (event) => {
setSize(event.target.value);
};

return (
<div>
<select
value={size}
onChange={handleSizeChange}
aria-label="Select product size"
>

<option value="small">Small</option>
<option value="large">Large</option>
</select>

{/* Based on user action within this page, the behavior of the product detail page above can be changed by changing the link to it */}
<Link href={`/products/${size}`}>
<button>
Open product page
</button>
</Link>
</div>
)
}

export default ProductListPage;

The most common ways of working with URL parameters in Next.js are to use dynamic route segments or query parameters. Dynamic route segments are a good fit for values that are required by a page to render. Query parameters are a good fit for optional values. You can find how to work with both in this post.

Cookies allow us to store small pieces of information in the browser. Once a cookie is set, it will be sent automatically by the browser for every request that is made to the domain that is specified in the cookie. This makes cookies a good fit for storing data about a user's session. Next.js has several helpers for working with cookies and you can find out how to use them in this post.

All state around what page a user is viewing and what state it is in can be stored in URL parameters. All state around a user's details and preferences can be stored in a cookie.

But we also use global state to cache data that has already been downloaded. How can we do the same here?

Using Next.js's caching features

Next.js has several caching features built-in and they are a better substitute to manually caching data using a global store. Here is a quick summary.

Request memoization

With this feature, all calls to the fetch API within a single page are memoized. What this means is that if you make the same GET request from several components within a page, the request will only be made once and the response will be shared across each usage of fetch. However, the response is only cached within a single render. If the page is reloaded, another request will be made. This feature is turned on by default.

Data cache

The data cache allows responses from an API to be cached across several requests to a page. This feature has to be enabled manually by specifying cache: 'force-cache' as an option when calling fetch. The cache can then be invalidated at certain intervals (using the revalidate option) or on-demand (using revalidatePath or revalidateTag).

Full route cache

If a page does not use any dynamic APIs or if no options are specified to fetch calls, Next.js will render the page during build time. The output of the render (HTML + RSC payload) will then be reused to serve all future requests for that page. This improves loading speeds since the server does not have to render the page each time it is requested. It also cuts the number of requests made to the API since the requests will only be made as the page is rendered during build time.

You can read more about Next.js's caching features here.

Closing thoughts

I hope that this post has given you a better idea about how you can manage state in a Next.js app. One of Next.js's biggest benefits over React SPAs is that it can pre-render pages to improve loading times. To use this benefit, we need to shift as much of the rendering to the server as possible and the methods shown in this post will help you to do just that.

Prabashwara Seneviratne (bash)

Written by

Prabashwara Seneviratne (bash)

Lead frontend developer