SSR data fetching + caching mechanism is a bit tricky in next js.
In this article, we will learn how to improve initial load time via SSR and have a high speed client side navigation with the help of CSR and React Query.
We will create a blog app using the JSON Placeholder API.
We are going to see just important sections here. To see the full source code checkout the github repo. You can also check the Live demo to get a better sight. The React Query devtools is available in this demo so you can check the cache flow.
Table of contents
- 1. Create a new project
- 2. Setup Hydration
- 3. Prefetching and dehydrate data
- 4. Shallow Routing
- 5. with-CSR HOC
- 6. handle 404 status code
- 7. Conclusion
- 8. Refrences
1. Create a new project
First, create a nextjs project:
yarn create next-app blog-app
or
npx create-next-app blog-app
Let's install React Query and Axios:
yarn add @tanstack/react-query axios
or
npm install @tanstack/react-query axios
2. Setup Hydration
Due to the react query documents we set up hydration in _app.js :
//pages/_app.js
import { useState } from 'react';
import { Hydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { config } from 'lib/react-query-config';
function MyApp({ Component, pageProps }) {
// This ensures that data is not shared
// between different users and requests
const [queryClient] = useState(() => new QueryClient(config))
return (
<QueryClientProvider client={queryClient}>
// Hydrate query cache
<Hydrate state={pageProps.dehydratedState}>
<Component {...pageProps} />
</Hydrate>
</QueryClientProvider>
)
}
export default MyApp;
3. Prefetching and dehydrate data
Before we continue note that In v3, React Query would cache query results for a default of 5 minutes, then manually garbage collect that data. This default was applied to server-side React Query as well. This lead to high memory consumption and hanging processes waiting for this manual garbage collection to complete. In v4, by default the server-side cacheTime is now set to Infinity effectively disabling manual garbage collection (the NodeJS process will clear everything once a request is complete).
Now we need to prefetch data and dehydrate queryClient in getServerSideProps
method :
//pages/posts/[id].js
import { getPost } from 'api/posts';
import { dehydrate, QueryClient } from '@tanstack/react-query';
export const getServerSideProps = async (ctx) => {
const { id } = ctx.params;
const queryClient = new QueryClient()
// prefetch data on the server
await queryClient.fetchQuery(['post', id], () => getPost(id))
return {
props: {
// dehydrate query cache
dehydratedState: dehydrate(queryClient),
},
}
}
P.S : We used fetchQuery
instead of prefetchQuery
, because prefetchQuery
doesn't throw any error or return any data. We will talk more about it in 6. handle 404 status code.
From now on we can easily use this prefetched data in our page, without passing any data by props.
Just to be clear lets take a look at the implementation of getPost
method and usePost
hook :
//api/posts.js
import axios from 'lib/axios';
export const getPost = async id => {
const { data } = await axios.get('/posts/' + id);
return data;
}
//hooks/api/posts.js
import { useQuery } from '@tanstack/react-query';
import * as api from 'api/posts';
export const usePost = (id) => {
return useQuery(['post', id], () => api.getPost(id));
}
Now we can use this usePost
hook to get post data.
//pages/posts/[id].js
import { useRouter } from 'next/router';
import { usePost } from 'hooks/api/posts'
import Loader from 'components/Loader';
import Post from 'components/Post';
import Pagination from 'components/Pagination';
const PostPage = () => {
const { query: { id } } = useRouter();
const { data, isLoading } = usePost(id);
if (isLoading) return <Loader />
return (
<>
<Post id={data.id} title={data.title} body={data.body} />
<Pagination id={id} />
</>
)
}
// getServerSideProps implementation ...
// We talked about it in section 2
4. Shallow Routing
We want to manage our data fetching and caching mechanism just in the client so we need to use shallow = true
prop in the Link component for navigating between post pages to prevent calling getServerSideProps
each time. This means that getServerSideProps
method will only call when the users directly hit the URL of the post and not in the client side navigation within the app.
We have a Pagination component to navigate between pages, so we use shallow = true
here :
//components/Pagination.jsx
import Link from 'next/link';
function PaginationItem({ index }) {
return (
<Link className={itemClassName} href={'/posts/' + index} shallow={true}>
{index}
</Link>
)
}
export default PaginationItem;
P.S : We used the new link component in nextjs v12.2 so we didn't need to use <a>
tag here.
5. with-CSR HOC
At this time nextjs v12.2 shallow routing only works for URL changes in the current page. nextjs shallow routing caveats
this means if you navigate from /posts/10
to /posts/15
with shallow = true
the getServerSideProps
won't call but if you navigate from /home
to /posts/15
the getServerSideProps
is called even you use shallow routing and this will fetch unnecessary data even if it's available in the cache.
I found a work around that checks if this request to getServerSideProps
is a client side navigation request or not. If it was, then returns an empty object for props and prevents fetching data on the server.
we can't prevent calling getServerSideProps
when navigating between different pages but we can prevent fetching unnecessary data in the getServerSideProps
.
Here is withCSR HOC implementation :
//HOC/with-CSR.js
export const withCSR = (next) => async (ctx) => {
// check is it a client side navigation
const isCSR = ctx.req.url?.startsWith('/_next');
if (isCSR) {
return {
props: {},
};
}
return next?.(ctx)
}
Now we should wrap our getServerSideProps
with this HOC.
//pages/posts/[id].js
import { getPost } from 'api/posts';
import { dehydrate, QueryClient } from '@tanstack/react-query';
import { withCSR } from 'HOC/with-CSR'
export const getServerSideProps = withCSR(async (ctx) => {
const { id } = ctx.params;
const queryClient = new QueryClient()
await queryClient.fetchQuery(['post', id], () => getPost(id))
return {
props: {
dehydratedState: dehydrate(queryClient),
},
}
})
If we navigate from different pages to post pages, getServerSideProps
won't fetch any data and it just returns an empty object for props.
6. Handle 404 status code
While Next.js renders an error page if a post is not available, it doesn't actually respond with an error status code.
This means that while you can be viewing a 404 error, the page is actually responding with a 200 code. To search engines, this essentially translates to: "Everything went fine and we found the page". Rather than actually responding with a 404, which tells search engines that page doesn't exist.
To resolve this issue, let's take a look at getServerSideProps
again :
const Page = ({ isError }) => {
//show custom error component if there is an error
if (isError) return <Error />
return <PostPage />
}
export const getServerSideProps = withCSR(async (ctx) => {
const { id } = ctx.params;
const queryClient = new QueryClient();
let isError = false;
try {
await queryClient.fetchQuery(['post', id], () => getPost(id));
} catch (error) {
isError = true
ctx.res.statusCode = error.response.status;
}
return {
props: {
//also passing down isError state to show a custom error component.
isError,
dehydratedState: dehydrate(queryClient),
},
}
})
export default Page;
7. Conclusion
We setup a caching mechanism with the ability to prefetch data on the server in SSR context. We also learned how to use shallow routing for faster client side navigation.
Here is the live demo of our implementation and the github repository for source code.
As well I had been added React Query devtools into production for you to understand thoroughly what is going under the hood.
I would like to extend my sincere thanks to @aly3n.
Top comments (5)
I've been passing data fetched server-side (specifically on-demand ISR) into a regular React Query instance via as initialData on the client side, because I didn't know about its hydration support. Any particular benefits to using rehydration vs initialData?
There are a few tradeoffs to consider when compared to the hydration / dehydration approach:
If you are calling
useQuery
in a component deeper down in the tree you need to pass theinitialData
down to that point.If you are calling
useQuery
with the same query in multiple locations, you need to passinitialData
to all of them.There is no way to know at what time the query was fetched on the server, so
dataUpdatedAt
and determining if the query needs refetching is based on when the page loaded instead.For more information please check here.
I've just migrated over to the de/hydrate based approach, and so far, so good!
Thanks for the pointer to dataUpdatedAt too!
This post was awesome !! Huge help, thank you !!
in my situation if i want to get response code of 429 return notFound: true
how can i do that?