skip to Main Content

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


  1. 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.

    enter image description here

    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.

    Login or Signup to reply.
  2. 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

    • Streaming is built into the Next.js App Router by default, allowing users to see parts of the page immediately before the entire content finishes rendering.
    • Streaming only happens when there is no route cache on the server.
      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:

    1. React renders Server Components into a special data format called the React Server Component Payload (RSC Payload), which includes references to Client Components.
    2. Next.js uses the RSC Payload and Client Component JavaScript instructions to render HTML for the route on the server.

    Then, on the client:

    1. The HTML is used to immediately show a fast non-interactive initial preview of the route.
    2. The React Server Components Payload is used to reconcile the Client and Server Component trees, and update the DOM.
    3. The JavaScript instructions are used to hydrate Client Components and make their UI interactive.

    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.

    Nextjs cache layers

    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:

    • Using a Dynamic API such as cookies, headers, connection, draftMode, searchParams prop, unstable_noStore
    • Using the dynamic = 'force-dynamic' or revalidate = 0 route segment config options
    • Opting out of the Data Cache

    Fetching

    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.

    // This is a server component
    export default async function Page() {
      const todoList = await fetchTodo();
      return <TodoList todos={todoList}></TodoList>;
    }
    

    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.

    // 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>;
    }
    

    Fetch and Resolve on the client

    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>
    }
    

    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.

    "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.

    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.

    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.

    Explaination

    • No. 1 and No. 2: These use 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.
    • No. 3 and No. 4: These use 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.
    • No. 5 and No. 6: These fetch data only on the client. The route is still pre-rendered and cached on the server with fallback data.
    • No. 7: This uses 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.
    • No. 8: This is similar to No. 7, except it uses 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.
    • No. 9 and No. 10: I tried to reproduce the behavior of useSuspenseQuery with useSyncExternalStore 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 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.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search