I have a reproduction for this issue here.
I’m playing around with NextJS app router, and Suspense.
I have two implementations of a simple client component that fetches data with React Query. One uses useSuspenseQuery
the other uses a regular query.
export function TodosRq() {
const query = useQuery<Array<{id: number, title: string}>>({ queryKey: ['todos'], queryFn: async () => {
await new Promise((res) => setTimeout(res, 10000));
const res = await fetch("https://jsonplaceholder.typicode.com/todos")
return res.json();
} })
return <div>
{query.data?.map((v) => {
return <div>
RQ
{v.id} {v.title}
</div>
})}
</div>
}
export function TodosRqSuspense() {
const query = useSuspenseQuery<Array<{id: number, title: string}>>({ queryKey: ['todos'], queryFn: async () => {
await new Promise((res) => setTimeout(res, 10000));
const res = await fetch("https://jsonplaceholder.typicode.com/todos")
return res.json();
} })
return <div>
{query.data.map((v) => {
return <div>
RQ
{v.id} {v.title}
</div>
})}
</div>
}
In my App router page, I can render either of these components:
{/* nb. this suspense boundary won't do anything */}
<Suspense fallback={'rq loading'}>
<h2>Todos RQ</h2>
<TodosRq/>
</Suspense>
or
<Suspense fallback={'rq loading'}>
<h2>Todos RQ</h2>
<TodosRqSuspense/>
</Suspense>
Intuitively, what I’m expecting here is that server render will render application in it’s loading state, stream that to the client, and then the client takes over and makes the API call.
However, what I actually observe, is that in the case of using the suspense query, actually NextJS applies static rendering to TodosRqSuspense
component. That is, in a production build, it returns prerendered HTML, never making the 10 second wait.
It’s important to observe both the behaviour of the dev server, as well as the production build.
Dev Server | Production Build | |
---|---|---|
TodosRq | We don’t see the suspense boundary. We wait 10 seconds till content appears. Content does not appear in the root document. | We don’t see the suspense boundary. We wait 10 seconds till content appears. Content does not appear in the root document. |
TodosRqSuspense | We see the suspense boundary. We wait 10 seconds till content appears. Content appears in the root document. (The dev server appears to do something funny where it can modify the response body of the network request) | We get the content immediately. Content is in the root document. |
What am I missing here?
Here’s the relevant parts of the documentation and how I understand it:
Static vs Dynamic rendering – for server components default behaviour is that all content will statically rendered (that is, rendered at build time), even if it involves data fetching, unless it fits into one of the exceptions, such as using the cookies
or connection
methods.
Client components – Client components are pre-rendered on the server, (first render on the server), and but then when they hit the client then the work is done on the client.
Suspense – Allows the ‘first render’ to show all the loading skeletons etc, and then if using RSCs then they’ll stream in, and, this is where I am having some misunderstanding, I would have thought that client components would still do their data fetching client side, and then display when they are complete.
What NextJS recommends – NextJS recommend that you do your data fetching in server components, just to be clear. However – within the context of some kind of migration, it makes sense that we might keep some things as client components. In any case, I’m trying to understand the nuance here.
nb. if we add useSearchParams
to our client component, then this behaviour does not occur and behaves inline with how I’ve intuited.
2
Answers
Checkout react-query documentation – https://tanstack.com/query/latest/docs/framework/react/guides/suspense#suspense-on-the-server-with-streaming and https://tanstack.com/query/latest/docs/framework/react/reference/useSuspenseQuery
It seems what you have described is expected behavior.
data
is guaranteed to be defined. And It can be statically fetched at build time.Also, as per the first documentation link above, there is a provision to get this working with suspense on server side.
First things first, I would like to explain some concepts to ensure we are on the same page. After that, I will use these concepts to answer your question.
Rendering
Server Components
Render only on the server.
Client Components
Render on both the server and the client.
Streaming
This is my assumption based on common sense: if the full HTML is already on the server, it can be sent to the client in one pass, eliminating the need for streaming.
Next.js also implies this by stating that streaming occurs when Next.js opts for dynamic rendering.
Render flow
On the server:
Then, on the client:
Caching
Cache Layers
The Next.js App Router introduces multiple cache layers that apply to various components of the application, such as data, RSC, and HTML.
My recommendation is to discard the old terms (SSR, CSR, SSG) and instead focus on the render flow of a Next.js application. Ask yourself: which parts of my application are cached? This approach can greatly help understand caching behavior in Next.js.
These are all the cache layers in a Next.js application. However, for this question, you only need to consider the Full Route Cache.
Full Route Cache
The default behavior of Next.js is to cache the rendered result (React Server Component Payload and HTML) of a route on the server. This applies to statically rendered routes at build time, or during revalidation. It is equivalent to Automatic Static Optimization, Static Site Generation, or Static Rendering.
Static Rendering
Equivalent to Full Route Cache
Dynamic Rendering
Opting out of Full Route Cache by using one of these methods:
cookies
,headers
,connection
,draftMode
,searchParams
prop,unstable_noStore
dynamic = 'force-dynamic'
orrevalidate = 0
route segment config optionsFetching
There are four ways of fetching data in a Nextjs app router
Fetch and Resolve on the server
The data will be fetched and the full HTML will be rendered on the server. Both RSC and HTML will then be streamed to the client.
Fetch on the server and Resolve on the client
The data will be fetched on the server and then streamed to the client as RSC. The HTML will be rendered on the client.
Fetch and Resolve on the client
The data will be fetched and the full HTML will be rendered on the client
Duplicating. Fetch and Resolve on the server and then Fetch and Resolve on the client
The data will be fetched and the full HTML will be rendered on the server, then they will be fetched and rendered on the client again. This might sound crazy, but it’s actually what
useSuspenseQuery
is doing.Understanding all of the above concepts is crucial to grasp the behavior of a Next.js application. Now, putting all of them together to answer your question.
Answer
I created a small site to demonstrate how all these things work together. You can check it out here.
This table shows the behavior of each route. Since almost all the optimizations in Next.js happen in the production build, we only need to consider the behavior of the production build.
Explaination
fetch
directly in server components withasync/await
, fetching and resolving the data on the server. Streaming is used when there is no Full Route Cache, i.e., during dynamic rendering.React.use
in client components to resolve data fetched on the server. Streaming is used when there is no Full Route Cache, i.e., during dynamic rendering.useSuspenseQuery
to fetch data on both the server and the client. Open the browser’s console log to check if the data is still being fetched.useSuspenseQuery
throws a promise to force the component to suspend on the server, making the renderer wait for the data before returning the HTML to the client. This is why the route is pre-rendered with full data.useSearchParams
, causing the Client Component tree up to the closestSuspense
boundary to be client-side rendered. In this case, the whole page will be client-side rendered. The route is still pre-rendered and cached with the fallback beingloading.tsx
. It might seem like streaming, but it is actually pre-rendered with a fallback on the server while fetching data on the client.useSuspenseQuery
withuseSyncExternalStore
and throwing a promise. These share the same behavior with No. 7 and No. 8.Conclution
To migrate from React Query in the Page Router to the App Router, you should use
useQuery
withReact.use
to fetch the data on the server and resolve it on the client. This minimizes effort and prevents duplicate data fetching.For the long run, I recommend removing React Query from the Next.js App Router and using
fetch
directly in server components. It’s not that Next.js is better than TanStack, it’s just that the Next.js App Router introduces many optimization and caching layers for fetching, which might overlap with React Query and add unnecessary complexity when used together.