How to store state in URL with Next.js app directory
TL;DR give me the code
Here’s a Next.js-compatible React hook that
import { ReadonlyURLSearchParams, useSearchParams } from "next/navigation";import { Dispatch, SetStateAction, useEffect, useState } from "react";export function useUrlSyncedState<T>( deserialize: (params: ReadonlyURLSearchParams) => T, serialize: (data: T, params: URLSearchParams) => void): [T, Dispatch<SetStateAction<T>>] { const params = useSearchParams(); // initialize state with deserialized params // params is always non-null in app directory, so we can use non-null assert const [state, setState] = useState<T>(() => deserialize(params!)); // store changed state in URL useEffect(() => { const params = new URLSearchParams(window.location.search); const oldParamsString = params.toString(); serialize(state, params); const str = params.toString(); if (str !== oldParamsString) { const newHref = `${location.pathname}${str === "" ? "" : `?${str}`}`; // https://github.com/vercel/next.js/discussions/18072#discussioncomment-109059 window.history.replaceState( { ...window.history.state, as: newHref, url: newHref }, "", newHref ); } }, [state, serialize]); return [state, setState];}// usage:const DESERIALIZE_PAGE = (params: ReadonlyURLSearchParams) => ( params?.has("page") ? parseInt(params.get("page")!, 10) - 1 : 0);const SERIALIZE_PAGE = ( page: number, params: URLSearchParams) => { if (page > 0) { params.set("page", String(page + 1)); } else { params.delete("page"); }};function MyComponent() { const [page, setPage] = useUrlSyncedState<PaginationState>( DESERIALIZE_PAGE, SERIALIZE_PAGE ); return <div>current page: {PAGE.pageIndex}</div>;}
Why URL state
Storing any larger or more complicated data in URL makes no sense, but there a few things for which URL state makes the most sense, such as search state and pagination.
In my case, I wanted a clientside search form to store its filter state in the URL. That way the user could freely click search results, explore them, and use back button to return to same results they departed from.
Existing implementations
There exists a next-usequerystate (https://github.com/47ng/next-usequerystate) specifically for this purpose, but it is relying on pre-next.js 13 code (like next/router
). Additionally, it seems to be primarily targeted at singular search params. We’ll want something that specifically targets Next.js 13 in order to stay maximally compatible with its routing system, while being flexible enough to be used for all kinds of usecases.
Core behavior
Core behavior in the hook is quite simple:
- Read URL search params when initializing
useState
useSearchParams
is conveniently available even during SSR in app directory
- Hook into state changes with
useEffect
to update URL when needed
We generally don’t have to worry about URL -> state
updates, since search param changes reload the page anyway and force useState
initializer to rerun in that way.
The biggest issue ended up being the state -> URL
update: we want to update the URL in a way that neither re-fetches data from server or breaks the Next.js navigation stack. The initial implementation looked something like the following:
window.history.replaceState("", "", newHref);
The problem with replaceState
API is that it is not by itself very Next.js compatible, so backwards/forwards navigation broke and didn’t properly change the shown client component. Next attempt was the obvious built-in router.replace
API:
router.replace(newHref);
The problem with this option was constant page reloads. Since modifying the search params in a URL is an operation that normally warrants reloading the page, Next.js helpfully refetches the serverside page contents every time router.replace
is called. For our purposes, we want to avoid any extra fetches.
So, let’s compromise. replaceState
while taking Next.js state into account:
const newHref = `${location.pathname}${str === "" ? "" : `?${str}`}`;// https://github.com/vercel/next.js/discussions/18072#discussioncomment-109059window.history.replaceState( { ...window.history.state, as: newHref, url: newHref }, "", newHref);