Leveraging the Loading UI in Next.js App Router
Lately, I've been diving deep into the new feature releases that came with Next.js 13 and 14. The latest version - Next 14, was released on the 26th of October 2023 and came with a couple of new features such as Turbopack and Server Actions along with new features like the disruptive App router and new data fetching methods.
Many of us old-time Next.js users probably haven’t started exploring the new Next.js features (or haven't started needing to). However, I’ve been quite intrigued by the new releases and what they claim to offer, and one of them is the Instant Loading State feature that enables you to pre-render a loading UI in a particular section of the page where data is still being fetched inside the server.
In case the last sentence sounds like gibberish, let me give an example; Let's say we have a blog post page that is being server-side-rendered. The page has a Navbar
, Menubar
, Footer
, The PostDetails
and finally, the Comments
section.
Now there are two things we would want to achieve:
We want to fetch the comments data during the server-side rendering, but,
We don't want to delay the display of other static pages like the
Navbar
,Menubar
,Footer
and thePostDetails
while waiting for the data in theComments
component to get fetched.
So what does Next.js do in this case? It temporarily replaces that comments component with a fallback UI to be shown in the browser while fetching the data needed for the comments section in the background (server). This concept probably already reminds you of something similar... yes you're right, the React Suspense!
In fact, the only difference is that Next.js' Loading state runs its suspense on the server, as opposed to the basic React Suspense which we usually only run on the client side.
So in our server, we have something like this:
The backbone mechanism behind this ability is something known as Streaming.
When you hear Streaming, you probably instantly think of YouTube or Netflix. Well, it’s not exactly that, but I promise it’s a little bit close!
Streaming is the mechanism that bridges the gap between fetching data from the server and loading pages quickly from the server side.
To explain this concept better, let's take a look at what happens under the hood during Client-side rendering (CSR) and Server-side rendering (SSR).
In Client-side rendering: React sends the HTML, CSS and Javascript to the browser through a script tag, and then the browser builds out the page and hydrates the page with the javascript. Then, the page becomes interactive.
You can see here that all the users see is a blank screen up until the entire page is loaded and it becomes interactive. Data fetching can only occur after this is complete.
In Server-side rendering: React (or in our case, Next.js) generates and builds out (renders) the HTML and CSS into a page on the server, and then sends that page to the browser. Similar to CSR, Javascript is also sent along and hydrates the page and makes it interactive.
A key advantage of SSR over CSR is that it allows data fetching to be done on the server and sent along with the page, hence, reducing the number of API calls to be made on the client. However, the user still sees a blank screen up until the page is displayed, and even after it is displayed, it still needs to wait for it to be completely hydrated before it becomes interactive. The wait takes longer, in fact, if data fetching needs to be done before the page is displayed. So the bigger the number or size of API calls on the server, the longer it takes before the page becomes fully interactive.
Problem statement: The user still sees a blank screen up until the page is displayed, and even after it is displayed, it still needs to wait for it to be completely hydrated before it becomes interactive.
So how do we solve this? What we need is a way to
Keep hidden only the parts that aren't ready to be displayed (ie. that are still being fetched) or replace them with a fallback loading UI, and;
Show the parts of the page that are ready to be displayed and do not require extra data fetching, eg, the menubar, navbar, footer etc, and make them interactive.
This is where the process of streaming comes in to save the day!
Take a look at this: Assuming we had our component structure like this:
Let us imagine that the data fetching only occurs on the Comments component.
Pre-streaming, the interactivity of the entire page and all its components will be deferred until the data fetching (that happens only in that comment section) is completed.
Here’s how Streaming suspends (pun intended, lol) that issue:
By using <Suspense/> with react, our page flow now looks like this
Sweet right? Super optimized too! Now the user doesn't need to feel like they’re waiting for anything at all because we’ve told React that it shouldn’t block the rest of the page from streaming—and, as it turns out, from hydrating, too! This means we no longer have to wait for all the code to load to start hydrating. React can hydrate parts as they’re being loaded.
Thanks to Selective Hydration and the advent of Server components, a heavy piece of JS doesn’t prevent the rest of the page from becoming interactive. So you can click around on the Menubar or the Navbar or footer while the comments are being fetched in the server.
Here’s how the render flow looks before and after, as described by Next.js
Before:
After:
Now to the fun part!
To take advantage of this powerful feature in your Next.js App Router, all you need to do is create a loading.tsx
(I'm using typescript, it can be .jsx in your case) at the same file level as your page.tsx
where the data fetching is going to happen, so something like this
You can see that the page.tsx
and the loading.tsx
in the posts/[postId] folder are on the same level, this lets Next.js know to automatically use that loading component as a fallback UI in the case of any server-side data fetching in its corresponding page.tsx
.
For now, let's have a simple text that says "Loading..." in our loading.tsx
file:
// posts/[postId]/loading.tsx
"use client";
import { Center, Text } from "@chakra-ui/react";
import React from "react";
export default function Loading() {
return (
<Center pt={20} w="full">
<Text color="white">Loading...</Text>
</Center>
);
}
Now let's fetch some data in our [postId]/page.tsx
so we can test if this loading component works;
// posts/[postId]/page.tsx
import React from "react";
const getCommentsData = async (postId: string) => {
try {
const res = await fetch(
`https://jsonplaceholder.typicode.com/comments?postId=${postId}`
);
if (!res.ok) {
// This will activate the closest `error.js` Error Boundary
throw new Error("Failed to fetch data");
}
return res.json();
} catch (error) {
console.log(error);
}
};
// We're adding this here to disable the default caching of subsequent
// requests so that the loading UI can be displayed even after refreshing
// the page.
export const dynamic = "force-dynamic";
export default async function EachPostPage({ params }: any) {
const data = await getCommentsData(params.postId);
console.log(data)
return (
<div>PostPage</div>
);
}
Easy as that! Now the getCommentsData
will get called on the server whenever a route like posts/1
or posts/abc
is visited, and you'll see the result of the console.log(data)
as an array of objects, each containing the name, id, text, and email of the comment.
Next, let's map the comments data into a list on our page, so our complete [postId]/page.tsx
looks like this:
// posts/[postId]/page.tsx
import { IComment } from "@/utils/types";
import { Box, Center, Heading, Stack, Text, VStack } from "@chakra-ui/react";
import React from "react";
const getCommentsData = async (postId: string) => {
try {
const res = await fetch(
`https://jsonplaceholder.typicode.com/comments?postId=${postId}`
);
if (!res.ok) {
// This will activate the closest `error.js` Error Boundary
throw new Error("Failed to fetch data");
}
return res.json();
} catch (error) {
console.log(error);
}
};
// We're adding this here to disable the default caching of subsequent
// requests so that the loading UI can be displayed even after refreshing
// the page.
export const dynamic = "force-dynamic";
export default async function EachPostPage({ params }: any) {
const data = await getCommentsData(params.postId);
return (
<Center w="full">
<VStack spacing={4} w="full" maxW="4xl">
<Heading color="gray.400" fontSize="xl">
POST ID: {params.postId}
</Heading>
<Heading color="gray.400" fontSize="xl">
Comments:
</Heading>
<Stack>
{data.map((comment: IComment) => (
<Box key={comment.id}>
<Text pb={4}>
Comment by <b>{comment.name}</b>
</Text>
<Text pb={4}>{comment.body}</Text>
</Box>
))}
</Stack>
</VStack>
</Center>
);
}
Now let's check this out on our browser! When we hit the /posts/[postId]
route (eg. say localhost:3000/posts/1
), we should see something like this:
Sweet! We now see that the rendering of the layout components (like the navbar in this example) is not delayed by the data fetching in the Comments
component. The navbar is rendered immediately and a fallback is provided for the fetching of the comments, and once the comments are fetched, they are displayed on the screen. Beautiful stuff 🚀
NB: You might not notice the loading UI for very long if you're on a good network speed - you might mostly just see a flash of the loading screen, but once you throttle to a slower network, you should be able to see it for longer
We now understand the principle of Streaming in React and how to effectively leverage it in Next.js by creating Loading pages in our App Router in order to make our websites and web apps as quick and responsive as possible.
And that's it! Till I come your way again guys, Happy Hacking!
Read More:
Subscribe to my newsletter
Read articles from Daniel Adetola directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Daniel Adetola
Daniel Adetola
I'm Daniel Ade, and I work as a Senior frontend engineer. I'm really good at React JS, Typescript, Gatsby, Next.js, GraphQL, Redux, Firebase, and every modern styling framework you can think of (seriously!)