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.
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.
Render only on the server.
Render on both the server and the client.
On the server:
Then, on the client:
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.
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.
Equivalent to Full Route Cache
Opting out of Full Route Cache by using one of these methods:
cookies
, headers
, connection
, draftMode
, searchParams
prop, unstable_noStore
dynamic = 'force-dynamic'
or revalidate = 0
route segment config optionsThere are four ways of fetching data in a Nextjs app router
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.
// This is a server component
export default async function Page() {
const todoList = await fetchTodo();
return <TodoList todos={todoList}></TodoList>;
}
The data will be fetched on the server and then streamed to the client as RSC. The HTML will be rendered on the client.
// This is a server component
export default async function Page() {
const todoPromise = fetchTodo();
// TodoListWrapper is a client component that use streamed data from the server
return <TodoListWrapper todoPromise={todoPromise}></TodoListWrapper>
}
// TodoListWrapper is a client component that uses streamed data from the server
"use client";
function TodoListWrapper({ todoPromise }: { todoPromise: Promise<Array<Todo>> }) {
const todo = use(todoPromise);
return <TodoList todos={todo}></TodoList>;
}
The data will be fetched and the full HTML will be rendered on the client
"use client"
export default function Page() {
const [data, setData] = useState()
useEffect(()=>{
fetchTodo().then(setData)
}, [])
return <TodoList todos={data}></TodoList>
}
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.
"use client"
export default function UseSuspenseQueryPage() {
const query = useSuspenseQuery<Array<Todo>>({
queryKey: ["todos"],
queryFn: async () => {
return await fetchTodo();
},
});
return <TodoList todos={query.data}></TodoList>
}
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.
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.
No | Technique | Full Route Cache | Streaming | Fetching Data | Resolve Data |
---|---|---|---|---|---|
1 | fetch | Yes | No | Server | Server |
2 | fetch + dynamic rendering | No | Yes | Server | Server |
3 | use | Yes | No | Server | Client |
4 | use + dynamic rendering | No | Yes | Server | Client |
5 | useEffect | Yes | No | Client | Client |
6 | useQuery | Yes | No | Client | Client |
7 | useSuspenseQuery | Yes | No | Both | Both |
8 | useSuspenseQuery + useSearchParams | Yes | No | Client | Client |
9 | useSyncExternalStore | Yes | No | Both | Both |
10 | useSyncExternalStore + useSearchParams | Yes | No | Client | Client |
Tip: To determine if a page is streamed, observe the browser's loading indicator and the timing of the document request. If the loading indicator remains active while the data is loading, and the HTML request time updates after the data is loaded (usually more than 5 seconds), then the page is streamed.
fetch
directly in server components with async/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 closest Suspense
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 being loading.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
with useSyncExternalStore
and throwing a promise. These share the same behavior with No. 7 and No. 8.To migrate from React Query in the Page Router to the App Router, you should use useQuery
with React.use
to fetch the data on the server and resolve it on the client. This minimizes effort and prevents duplicate data fetching.
function TodoList({ query }: { query: UseQueryResult<Todo[]> }) {
const data = React.use(query.promise)
return (
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}
export function App() {
const query = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
return (
<>
<h1>Todos</h1>
<React.Suspense fallback={<div>Loading...</div>}>
<TodoList query={query} />
</React.Suspense>
</>
)
}
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.