Cover Image
All posts

Granular Data Loading in Next.js: Prefilling React-Query within Pages Router & getLayout Pattern

Modern web applications require sophisticated data-fetching strategies and seamless user experiences, especially at scale. While exploring alternatives to the Next.js App Router and experimenting with TanStack Start (kudos to Tanner Linsley), I discovered a powerful pattern upon returning to my beloved Next.js: combining the traditional Pages Router with the getLayout pattern and pre-filling the react-query client on the server side using getServerSideProps. This approach offers several benefits:

  • Granular Data Loading & Enhanced Perceived Performance: Fetch only essential data needed for the main page experience, minimizing initial server-to-client data transfer. Secondary data is then loaded from the client side, gradually enhancing the experience (distinct from progressive enhancement).

  • Clear Suspense Boundaries: Use Suspense for better developer and user experience with client-side loading states. Assuming major components are built Suspense-ready by default (as explained below), they remain unaware whether data is pre-loaded or lazy-loaded (fetch-as-you-render).

  • Maintainability & Flexibility: Using the getLayout pattern, it’s easy to separate layout concerns from page content without major component refactoring. With components being data-loading agnostic (pre-loading vs. lazy-loading), it’s easy to move them around in the tree. Using react-query reduces prop-drilling, even in tightly coupled components (for example, in a specific List component and its adjacent ListItem component).

In this article, I will explore the patterns, benefits, and implementation details to help you adopt this strategy in your Next.js projects. First, let me outline how I personally approach building client-side applications with SSR, and how that approach led me to combine these building blocks into the pattern presented here.

  1. During development, I prefer to fetch all data on the client side initially. No need to think about data loading and dependencies while figuring out animations, layout breakpoints, or UX flows in general. This explicitly excludes chunks that have repeatedly demonstrated the need to be loaded on the server on other pages, such as the current user’s account, or chunks that need to be loaded on the server by nature, such as auth state.

  2. As the application matures (even before the first deployment) and fetching dependencies become clearer, I prefer to selectively opt in specific chunks of data to be loaded on the server. This is mostly guided by the experience the visitor/user is supposed to have, so doing this after having figured out the UX feels like an experience-driven approach. Being a Next.js user mainly, I use getServerSideProps to obtain the data on the server and then (depending on the kind of data) find an appropriate solution to getting that chunk down to the component.

  3. This means that in most cases, components which rely on data may need to change how those chunks are made available to them. This is why I often like to scaffold a Suspense boundary directly into major components, right from the get-go. This means I am building data-loading agnostic components which adapt to changing requirements without re-implementations. For example, if a particular flow would benefit from pre-loaded data, I can easily opt in to pre-loading on the server. When data is pre-loaded, those components skip the loading state; when lazy-loading on the client, they display the loading state initially until the data is available.


Here’s a concrete example: Consider a Comments component displaying secondary content alongside primary data. To optimize Time to First Byte (TTFB) and other performance metrics (FCP/LCP), you might initially load comments on the client side, showing a skeleton UI. However, what if you later need to reuse this Comments component on a dedicated comments page, where comments are the primary content? You have two traditional options: either modify the component to accept pre-loaded data through props/context/state management, or create a specialized variant. Both approaches require changing the component’s implementation.

If the component is data-loading agnostic (using react-query with Suspense boundaries), no implementation changes are needed. When comments data is pre-loaded, the component automatically skips the skeleton UI and displays comments immediately.


Now that the context and vision are clear, let’s explore how to implement this concept by combining getLayout, Suspense, getServerSideProps, and react-query on both client and server.

The Fundamentals of the Pages Router with getLayout

The traditional approach in Next.js to routing uses the pages directory. The getLayout pattern offers a convenient way to define layouts on a per-page basis within this routing structure. With getLayout, you can wrap page components in distinct layouts without polluting each component’s code. Each page can define its own layout. The key advantage of this pattern is that the layout exists outside of individual route components, preserving its state. When two routes share the same layout, navigating between them only updates the page component while the outer layout remains static and reconciled (assuming no layout shift occurs).

_app.tsx with getLayout pattern applied

pages/_app.tsx
import {NextPage} from "next"
import type {AppProps} from "next/app"
import {ReactElement, ReactNode, Suspense, useState} from "react"
 
export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
  getLayout?: (page: ReactElement) => ReactNode
}
 
type AppPropsWithLayout = AppProps & {
  Component: NextPageWithLayout
}
 
export default function App({Component, pageProps}: AppPropsWithLayout) {
  const getLayout = Component.getLayout || ((page) => page);
  return getLayout(<Component {...pageProps} />)
}

pages/dashboard.tsx using getLayout pattern

pages/dashboard.tsx
type DashboardProps = {}
 
function Dashboard(props: DashboardProps) {
  return (
    <div>
      <h1>Dashboard</h1>
      <pre>{JSON.stringify(props, null, 2)}</pre>
    </div>
  );
}
 
Dashboard.getLayout = function getLayout(page) {
  return (
    <MainLayout>
      {page}
    </MainLayout>
  );
};
 
export default Dashboard;

If you want to read a bit more about this pattern, head over to https://nextjs.org/docs/pages/building-your-application/routing/pages-and-layouts#per-page-layouts

Pre-filling the React-Query Client on the server

The core of this pattern involves using Next.js’s getServerSideProps server-side loading paradigm to prefetch and hydrate data for our client-side React Query client. The process consists of three main steps:

  1. Prefetching Data: Using react-query’s queryClient.prefetchQuery within getServerSideProps.

  2. Hydrating Data: Passing the dehydrated state as a prop to your page component / _app.tsx.

  3. Rehydrating on the Client: On the client side, using React Query’s HydrationBoundary component, rehydrate the cache. This adds all the queries that have been fetched on the server to the client-side cache. All subscribers to those queries will skip loading states and can access the data directly.

There is a problem though. While this is a good concept, manually initializing, handling, dehydrating, and hydrating our query client requires a lot of work that we wouldn’t want to implement on each page. Additionally, the HydrationBoundary needs to be inserted above the page component, which could lead to potential issues. Luckily, _app.tsx and TypeScript provide solutions.

Let’s define a wrapGetServerSideProps function that injects a queryClient into our context, which is then passed down to the getServerSideProps handler. In each handler, we will pre-fill the queryClient with data. Before returning the props for the page, wrapGetServerSideProps will automatically dehydrate the query client’s cache and pass it down alongside the props. This eliminates the need to handle this process manually on each page.

Export CommonProps and wrapGetServerSideProps from server.ts

server.tsx
import {dehydrate, DehydratedState, QueryClient} from "@tanstack/react-query"
import {GetServerSideProps, GetServerSidePropsContext, GetServerSidePropsResult, PreviewData} from "next"
import {ParsedUrlQuery} from "querystring"
 
export type CommonProps = {
  dehydratedState: DehydratedState
}
 
export function wrapGetServerSideProps<
  Props extends {
    [key: string]: any
  } = {
    [key: string]: any
  },
  Params extends ParsedUrlQuery = ParsedUrlQuery,
  Preview extends PreviewData = PreviewData,
>(
  handler: (
    props: GetServerSidePropsContext<Params, Preview> & {queryClient: QueryClient},
  ) => Promise<GetServerSidePropsResult<Props>>,
): GetServerSideProps<Props & CommonProps> {
  return async (context) => {
    const dehydrateableContext = context as GetServerSidePropsContext<Params, Preview> & {queryClient: QueryClient}
    dehydrateableContext.queryClient = new QueryClient()
 
    const result = await handler(dehydrateableContext)
    if (!("props" in result)) {
      return result
    }
 
    const props = await result.props
    return {
      props: {
        ...props,
        dehydratedState: dehydrate(dehydrateableContext.queryClient),
      },
    }
  }
}
 

Of course, we could easily hydrate the query client’s cache within the component. But keep in mind that we potentially have getLayout attached to our Component, which may need some data as well and is mounted higher in the tree than the page component, where we would inject the state. So let’s handle the dehydratedState only once in the top-most level in our _app.tsx, where we have access to all the props as well.

Automatic react-query hydration in app.tsx

pages/_app.tsx
import {HydrationBoundary, QueryClient, QueryClientProvider} from "@tanstack/react-query"
import {NextPage} from "next"
import type {CommonProps} from "@/server"
import type {AppProps} from "next/app"
import {ReactElement, ReactNode, Suspense, useState} from "react"
 
export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
  getLayout?: (page: ReactElement) => ReactNode
}
 
type AppPropsWithLayout = AppProps & { pageProps: CommonProps } & {
  Component: NextPageWithLayout
}
 
export default function App({Component, pageProps}: AppPropsWithLayout) {
  const [queryClient] = useState(() => new QueryClient())
 
  const getLayout = Component.getLayout ?? ((page) => page)
 
  return (
    <QueryClientProvider client={queryClient}>
      <HydrationBoundary state={pageProps.dehydratedState}>
				<Suspense>{getLayout(<Component {...pageProps} />)}</Suspense>
      </HydrationBoundary>
    </QueryClientProvider>
  )
}

This approach ensures that data is loaded on the server with getServerSideProps and then rehydrated on the client. The client-side query intelligently uses the preloaded data when available, eliminating unnecessary re-fetches and preventing unwanted suspense boundary triggers.

pages/dashboard.tsx pre-filling the react-query client

pages/dashboard.tsx
type DashboardProps = {}
 
function Dashboard(props: DashboardProps) {
  const {data, isLoading} = useQuery(someQueryOptions());
  
  if (isLoading) {
	  return <div>Loading ...</div>
  }
  
  return (
    <div>
      <h1>Dashboard</h1>
      <pre>{JSON.stringify(props, null, 2)}</pre>
    </div>
  );
}
 
Dashboard.getLayout = function getLayout(page) {
  return (
    <MainLayout>
      {page}
    </MainLayout>
  );
};
 
export const getServerSideProps = wrapGetServerSideProps<DashboardProps>(async (context) => {
	await context.queryClient.prefetchQuery(someQueryOptions())
	return {props: {}};
})
 
export default Dashboard;

Since we are loading someQuery in getServerSideProps, the page will be properly server-side rendered (isLoading is never true) because the data is available before the page component renders on the server. As we send the data to the client directly with the HTML, it will hydrate properly without requiring an additional loading cycle. Any other component that needs someQuery (which we don’t need to know about at this point) will also render instantly.

How can we keep the UI usable even if we are not pre-loading some query keys on the server? I mentioned components being pre-/lazy-loading agnostic. Let’s see how we can achieve that.

Leveraging Suspense with useSuspenseBoundary

Suspense is a powerful feature in React that lets you declaratively specify UI for loading states. In situations where some data is fully loaded on the server and other parts need client-side fetching, using useSuspenseQuery instead of useQuery can create a smooth experience.

Imagine dynamic UI components that fetch additional content (secondary content) after the initial render. You can wrap these components with a Suspense boundary to provide a fallback UI until the data is ready.

Adding comments to pages/dashboard.tsx which load lazily

pages/dashboard.tsx
import { useSuspenseQuery } from 'react-query';
 
function Comments() {
	const {data} = useSuspenseQuery(someOtherQueryOptions());
  return (
    <div>
      <h2>Comments</h2>
      <pre>{JSON.stringify(data, null, 2)}</pre>
    </div>
  );
}
 
function CommentsFallback() {
	return <div>
     <h2>Comments</h2>
     <div>Loading...</div>
  </div>
}
 
type DashboardProps = {}
 
function Dashboard(props: DashboardProps) {
  const {data} = useQuery(someQueryOptions());  
  return (
    <div>
      <h1>Dashboard</h1>
      <pre>{JSON.stringify(props, null, 2)}</pre>
      
      <hr />
      
      <Suspense fallback={<CommentsFallback />}>
	      <Comments />
      </Suspense>
    </div>
  );
}
 
Dashboard.getLayout = function getLayout(page) {
  return (
    <MainLayout>
      {page}
    </MainLayout>
  );
};
 
export const getServerSideProps = wrapGetServerSideProps<DashboardProps>(async (context) => {
	await context.queryClient.prefetchQuery(someQueryOptions())
	return {props: {}};
})
 
export default Dashboard;

Here’s what happens in this example: First, the page renders on the server, producing HTML with an inlined JSON representation of the react-query cache. The getServerSideProps function executes before the page renders to HTML, making all fetched data available during the server render phase.

On the client side, when the react-query cache hydrates into the react-query client, the data for someQuery becomes available during the initial hydration render phase. The server-rendered HTML matches the client-side render perfectly because all data was shipped together. This eliminates additional data round-trips that would trigger loading states and cause hydration errors.

When mounting comments, they trigger a client-side fetch of someOtherQuery. During this fetch, the Suspense boundary (placed higher in the component tree) displays the fallback UI. Once the data arrives, it renders the actual content.

If we later determine that Comments are essential for the initial page render, we can simply pre-fetch the data by adding context.queryClient.prefetchQuery(someOtherQueryOptions()) to getServerSideProps. And boom 🎉, the component appears in the first render.

This clean separation ensures immediate rendering of primary page elements (through server-side hydration) while enabling gradual enhancement where appropriate.

Personal Learnings

Combining these building blocks allowed me to build various React applications without deeply embedding the data loading logic. In codebases where I used this pattern, components began to lose more and more props. I realized that eventually, the clean interfaces typically associated with server components became achievable within the pages router. Some parts of the code even began looking “too” polished, as if it were an implementation you would use to demonstrate composition. For example, list-item components that only receive the item’s id become practical: react-query handles data retrieval based on the route’s loading strategy. If a loading state is necessary, it will provide one.

I also noticed that components naturally evolved to include fallbacks and empty states (via Suspense) from the start. This approach enables components to work seamlessly whether they’re lazy-loading or server-loaded, without needing to know which method is being used.

Conclusion

Combining the Next.js pages router with the getLayout pattern and prefetching react-query data on the server creates a robust and flexible approach to handling data in modern web applications. When leveraging getServerSideProps for essential and primary data and interweaving Suspense boundaries for lazy-loaded secondary content, developers can achieve granular and scalable patterns which can be tailored to the experience the user should have.

This pattern is especially valuable when you need fine-grained control over your data-fetching strategy and want to ensure that both primary and secondary content loads efficiently. Make sure to experiment with these techniques and tailor them to your specific project requirements to harness their full potential.

If you liked this post you might want to follow me on twitter for updates on new posts and other content I create. If you want to continue reading you will find a list of all posts I have written so far.
Title
Published At