Up until now, we have been working with dynamically rendered server components. But, there are other options.
Client components
Client components are components that:
- Have interactivity and event listeners.
- Use hooks (f.e.
useState
) or custom hooks. - Use browser only API (f.e. localstorage or geolocation)
- Are Class components.
Can we have Suspense
in or around client components? No! I added the use client
directive on top of our <Post />
component and the entire page lit up with eslint yellow squiggly lines. It had a link to the eslint rule that states clearly:
Client components cannot be async functions:
https://nextjs.org/docs/messages/no-async-client-component
This page has some more links saying it's technically possible to make client components async but very glitchy and complex, so stay away from it. With no async client components, there is no need for Suspense
.
Static rendering
We've been using dynamic fetches (with option { cache: 'no-store }
). This opts Next
into dynamic rendering of the entire route. This means that the route is not prerendered at build time (next build
) but at request time. When the user requests a page, the server prerenders the page and sends it to the client.
Static rendering works differently. The route gets prerendered - server-side - at build time. When the user requests a page, the server serves this prerendered page. The prerendering has already been done. Static rendering is the default in Next
. Every render is static except when Next
encounters a dynamic fetch (see above) or dynamic functions (like headers()
or cookies()
).
Let's test this out. We will make a new route /test4
but base our project on /test2
(the one with loading.js
). (All files are available on github)
As we are working with dynamic pages we need to tell our app what pages we want to generate using the generateStaticParams
function. (This replaces getStaticPaths
):
// app/test4/[pageId]/page.tsx
export async function generateStaticParams() {
const ids = [1, 2, 3, 4, 5];
return ids.map((id) => ({ pageId: `${id}` }));
}
...
If you're not quite following: we have a dynamic page: [pageId]. To do static rendering (SSG) we provide Next
with the routes we want prerendered. For this we use generateStaticParams
that returns an array of objects with the pageIds:
[{ pageId: 1 }, { pageId: 2 }, ...]
What pages do we want to prerender? Pages 1 to 5. And that is what we did here.
But, we are not finished. Our <Post />
component uses a dynamic fetch ({ cache: 'no-store' }
). But, we want a static fetch. So we make a copy of <Post />
and call it: <StaticPost />
:
// components/StaticPost.tsx
import getRandomInt from '@/lib/getRandomInt';
import pauseFunction from '@/lib/pauseFunction';
type Props = {
delay: number;
};
type Post = {
userId: string;
id: number;
title: string;
body: string;
};
export default async function StaticPost({ delay }: Props) {
const randomPostId = getRandomInt(1, 100);
const url = `https://jsonplaceholder.typicode.com/posts/${randomPostId}`;
const res = await fetch(url, { cache: 'force-cache' });
const post: Post = await res.json();
const pause = await pauseFunction(delay);
return <li>{post.title}</li>;
}
The only thing we changed was replacing { cache: 'no-store' }
with { cache: 'force-cache' }
. We could've also omitted it since force-cache
is the default. Lastly, we of course update our dynamic page with this <StaticPost />
.
Before we run the test, there is a caveat. We need to run a build. When in development mode, there are no prerendered files and Next
just runs everything server-side. So, we need to first run next build
and then next start
. Then we can test our example in 'production' mode.
Running build confirms our static rendering. The terminal confirms that our /test4
paths were statically generated (the symbol ● confirms this):
└ ● /test4/[pageId]
├ /test4/1
├ /test4/2
├ /test4/3
└ [+2 more paths]
If you are interested you can even look inside the Next
build folder: .next/server/app/test4/
and see our 5 prerendered page: 1.html
, 2.html
, ...
Let us now run the app: next start
and open it in localhost. When we navigate to test4/1
and go further to 2 and 3, it is clear. Navigation is immediate. There is no loading, there is no waiting. The server serves the prerendered files - as expected. Conclusion: Suspense
is useless in static rendering? No. Let's run some more tests.
dynamicParams
Next
SSG has a cool feature. We just told Next
to prerender pages 1 to 5. But what happens when we were to visit 'page 6' at test4/6
? In the same file where you use generateStaticParams
you can export an option named dynamicParams
:
export const dynamicParams = true; // true | false
When this option is set to false, opening a route that was not generated by generateStaticParams
(f.e. /test4/6
) will generate a 404. But when set to true (the default), Next
will just prerender this page dynamically and serve it. This page will then exist in the Next
build folder as 6.html
as if it was prerendered at build time. When you or another user then request this page, it will be served from this prerendered folder, exactly like the other prerendered page (1 to 5). In the old pages router
, dynamicParams
was the fallback option to getStaticPaths
.
Did I hear server-side rendering? Bring in the Suspense
. Let's update our test to include some non-prerendered links. In app/test6/layout.tsx
we add links to pages 6 to 10. But, we do not update the generateStaticParams
with these pages. We do not want to prerender pages 6 to 10. Run build, check terminal logs:
└ ● /test4/[pageId]
├ /test4/1
├ /test4/2
├ /test4/3
└ [+2 more paths]
Success, pages 6 to 10 were not prerendered. Run start, navigate to page /test4/1
, then /test4/2
. All fine, not loading state. To the new not-prerendered pages: /test4/6
. Result: no loading state, immediate rendering of the post title. What? Same thing for pages 7,8,...
This surprised me. Maybe Suspense
does not work here? (It does!) Did I do something wrong? (I did not.) Even more surprising. The Next
build folder now had prerendered files of these new routes: 6.html
, 7.html
, ...
Eventually it clicked. I made a new build, opened the network tab of my browser dev tools, ran start and navigated to route /test4
. This is what the network tab showed:
Next
had prefetched all the new routes (6 to 10) in the background. On navigating to these routes it then served me prerendered files that were prerendered at prefetch time.
After my initial new build, the Next
build folder held pages 1 - 5 for route /test4
. The second I visited this route (and the links appear), Next
prefetched them. Pages 6 to 10 were now also present in the build folder. How awesome is that!
But, no Suspense
needed then?
No prefetching
Well, what happens is that Next
only prefetches links that are inside the viewport. So, if you were to have maybe a lot of links low down in the page it is possible to run into an unprefetched page. We will simulate this by adding some links with the option prefetch false:
// prettier-ignore
<Link href='/test4/11' prefetch={false}>11</Link>
Suspense
needed? Yes. We added 5 more links to pages 11 - 15 with the prefetch false option. Run build and start. When visiting these non-prefetched functions ... the app hangs. It pauses for around 3-ish seconds. Then the address bar and the components update. Our <Loader />
wasn't visible. So, loading.tsx
did not work!
I can't explain this. I fully expected it to work. After some testing and trying I eventually managed to get a loading state by adding a loading.js
in the /test4
folder. (I used a green loading text so I could tell the difference.)
/test4
page.tsx
layout.tsx
loading.tsx (Works)
/[pageId]
page.tsx
loading.tsx (Does not work)
Loading:
After loading:
Unfortunately I can't explain this. Is this normal behavior or a bug? I don't know. I expected 'normal' behavior for non-prefetched routes, similar to dynamic routes. This did not happen and I can't explain why.
What I can say it that our pages 11 to 15, after being visited did appear in our build folder - as expected.
Streaming
One last note. Next
has been updating a lot of its docs lately and while doing some research for this article, I came across some new info. Earlier in this article we talked about dynamic and static server-side rendering.
But in the recently updated docs, Next
now mentions 3 types of server-side rendering:
- Static: default.
- Dynamic: when using dynamic functions or dynamic fetches.
- Streaming: when using loading.js or
Suspense
.
The docs define streaming as:
With Streaming, routes are rendered on the server at request time. The work is split into chunks and streamed to the client as it becomes ready.
Source: next docs
That means that we've been talking about streaming
the whole time. Does this change anything about this article? No, streaming
is always paired with dynamic rendering. We just explored static rendering and affirmed this. Statically prerendered routes don't need Suspense
.
Conclusion Challenge
I can't offer a conclusion so instead I will end this bonus chapter in a challenge.
I setup the example, and it is available on github. Give it your best shot and see if any of you can explain why loading.js
is not working as intended in not prerendered, not prefetched routes.
As far as using loading mechanics in your own SSG projects. As I am not sure this is a bug or not, I would say, be very careful.
Top comments (4)
Great article Peter, thank you. "Next will just prerender this page dynamically" threw me off a bit, is it a typo? Either it pre-renders (static rendering) or it renders on request (dynamic rendering), right? Thanks again!
PS: For future visitors it now makes sense with
Suspense
around client components, as we can pass promises to them and suspend the rendering of it until the promise has been resolved (withuse()
): react.dev/reference/react/useThe docs may have updated but Next tends to consider both to be pre-rendering.
Thanks for the awesome write up on this. I'm searching for a similar answer to what you found at the end here with some subset of pages in a dynamic route being rendered statically at build time, some rendered via prefetch from next/link elements, and the remainder streamed with a loading UI one first render and statically rendered on future request.
When you mentioned at the end of the No prefetching section that you were able to get it working by adding a loading.js file to the /test4 folder, I'm guessing that the caused all the pages in the folder to now load dynamically (always show a loading UI, and never the static UI from server side render)?
Wondering if this is just a feature gap in nextjs, where you are ether always using a loading UI or always having the user wait for the server side UI generation to finish for previously ungenerated routes. Would be great if they had this state where IF there was no previously generated file, it will first show the loading UI, while in it generated the static UI. But then on subsequent requests to this route, detect that the static file for that route had now been generated, and return the static file instead.
It's been a while since I wrote this so I had to have a good think. I tested this out by running a new build, going to test 4 and opened pages 11 and 12. Then I closed my browser. Pages 11 and 12 where now in my .next prerendered folder. I opened the little app again and navigated to pages 11 and 12 and they did show a very very quick green loading text. So at first glance, it does seem like loading is always on.
But, we had the prefetch option turned off. With the prefetch option turned on there is no loading state. It's complicated. The prefetching mechanism seems to save us from what you call the feature gap.
In a real live app you'd have to add even more complexity like revalidation. Not sure how loading.js fits into that. It's messy.