DEV Community

Peter Jacxsens
Peter Jacxsens

Posted on

Using Suspense in NextJs

Before we move to the new model of caching (cache components), I want to take a look at how <Suspense> interacts with static and dynamic rendering in the not partial prerendering model.

static vs dynamic rendering

Quick reminder: dynamic rendering happens when a route contains at least one of the following elements:

  • cookies function
  • headers function
  • connection function (more later)
  • draftMode function
  • searchParams prop
  • unstable_noStore (legacy)
  • dynamic fetches: fetch with { cache: 'no-store' }

Dynamic rendering means that the route will be rendered server-side at request time. This render is not cached, so every visitor gets a fresh and personal render. Any route that isn't dynamic will be statically rendered. That is it will be prerendered and cached at build time. These prerendered files are stored in the .next build folder. Each visitor gets served the same prerendered content. This is called the full route cache.

Suspense

The Next docs don't explain much about the <Suspense> component:

<Suspense> works by wrapping a component that performs an asynchronous action (e.g. fetch data), showing fallback UI (e.g. skeleton, spinner) while it's happening, and then swapping in your component once the action completes.

This swapping out is called streaming. Let's see some examples. Note: the examples are available on github. We reuse the <Post /> component from earlier:

// components/Post.tsx

type Props = {
  postId: number;
  fetchOptions: RequestInit;
};

export type PostT = {
  id: number;
  title: string;
};

export default async function Post({ postId, fetchOptions }: Props) {
  const data = await fetch(
    `https://jsonplaceholder.typicode.com/posts/${postId}`,
    fetchOptions,
  );
  const post: PostT = await data.json();
  // get date from headers
  const fetchDate = data.headers.get('date');
  const time = fetchDate
    ? new Date(fetchDate).toTimeString().slice(0, 8)
    : null;
  return (
    <div className='flex gap-2'>
      <div className='text-orange-400 mr-2'>{time}</div>
      <div className='font-bold'>{post.id}.</div>
      <div className='italic'>{post.title}</div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

And we load it in a static route:

// app/suspense/static/page.tsx

export default function Page() {
  return (
    <>
      <Header />
      <Suspense fallback='...loading'>
        <Post postId={50} fetchOptions={{}} />
      </Suspense>
      <Footer />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

We also added a header and footer:

// components/static/Header.tsx
export default function Header() {
  return <header>***** header *****</header>;
}

// components/static/Footer.tsx
export default function Footer() {
  return <footer>***** footer *****</footer>;
}
Enter fullscreen mode Exit fullscreen mode

And we run next build. The build log reveals that the route was rendered statically: ○ /suspense/static. But, more importantly, when we look into the .next build folder, we can see that everything was prerendered, including the async data:

<!--
  .next\server\app\suspense\static.html
  cleaned out
-->

<!doctype html>
<html lang="en">
  <body>
    <header>***** header *****</header>
    <!--$-->
    <div class="flex gap-2">
      <div class="text-orange-400 mr-2">16:46:44</div>
      <div class="font-bold">50<!-- -->.</div>
      <div class="italic">
        repellendus qui recusandae incidunt voluptates tenetur qui omnis
        exercitationem
      </div>
    </div>
    <!--/$-->
    <footer>***** footer *****</footer>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

We can clearly see the header, footer and the <Post /> content. So Next resolves the promises during build time.

I got a bit curious at this point and wrote a new test with an button that calls updateTag (we used the <UpdateTagActionButton /> component earlier):

// app/suspense/updateTag/page.tsx

export default function page() {
  return (
    <>
      <Suspense fallback='Loading...'>
        <Post postId={51} fetchOptions={{ next: { tags: ['post-51'] } }} />
      </Suspense>
      <UpdateTagActionButton tag='post-51' />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

So, we got a static route with a tagged post, wrapped in suspense. The <UpdateTagActionButton /> component is just a button that calls a server action where we call updateTag on tag "post-52". So, what happens? Will we get: Loading... fallback or not?

Nope! Honestly, I wasn't sure what would happen here. It seems that server actions and the read your own writes mechanic do not trigger the <Suspense /> fallback.

Another test with suspense in a dynamic route:

export const dynamic = 'force-dynamic';

export default async function Page() {
  return (
    <>
      <Header />
      <Suspense fallback='Loading...'>
        <Post postId={52} fetchOptions={{}} />
      </Suspense>
      <Footer />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

It's a dynamic route. That means no static rendering and no full route cache to take a look at. The build log confirms that it is a dynamic route. When we run next start and visit this example we get a very quick loading state when we load or reload the page:

using suspense

This is pure streaming. The initial dynamic render shows the static components and the fallback. (This is called the static shell) Once Next resolves <Post /> in the background, the rendered result is streamed in.

What is the advantage of this? There is no blocking. Without suspense, the server must wait for every fetch to finish before sending a single byte to the browser. With suspense, the static shell is immediately send over, giving the user instant visual feedback.

Are we - partially prerendering - yet?

No, but we're getting very close. In the dynamic example above, the server had to generate the static shell at request time. In partial prerendering, that shell would have been rendered at build time while only suspense children would get rendered at request time.

useSearchParams hook

Way back in chapter 3 of this series, we covered static rendering of client components. As a quick reminder, client components are components that:

  • use hooks or custom hooks
  • have interactivity (event listeners)
  • use browser only API's like localStorage or geolocation.

We found out that client components can be statically rendered into HTML and rsc. The HTML is served on initial visit, rsc on client side routing. We also discovered that client components that use hooks can be statically prerendered into HTML. For example initiating state with 1 and then rendering a count with this state will result in prerendered HTML: "count: 1". The useState hook and most other hooks can be prerendered.

But, one hook is different: useSearchParams. Take this example:

// app/suspense/useSearchParams/page.tsx

export default function Page() {
  return (
    <>
      <Header />
      <Hello />
      <Footer />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

And <Hello /> is our client component with the useSearchParams hook:

// components/client/Hello.tsx

'use client';

import { useSearchParams } from 'next/navigation';

export default function Hello() {
  const searchParams = useSearchParams();
  return <div>Hello, {searchParams.get('name')}</div>;
}
Enter fullscreen mode Exit fullscreen mode

When we run this in dev mode on route: http://localhost:3000/suspense/useSearchParams?name=peter (note the searchParam) we see this:

***** header *****
Hello, peter
***** footer *****
Enter fullscreen mode Exit fullscreen mode

However, once we run build, we get an error:

⨯ useSearchParams() should be wrapped in a suspense boundary at page "/suspense/useSearchParams".
Enter fullscreen mode Exit fullscreen mode

What is going on here? We are in a statically rendered route, that is at build time. At build time there is no Request and also no searchParams. There is no searchParams.name, so Next cannot prerender this route.

How do we solve this? We have two options:

  1. Make the route dynamic. At request time, Next will have access to searchParams.
  2. Keep the route static by using suspense.

We update our example with suspense:

export default function Page() {
  return (
    <>
      <Header />
      <Suspense fallback='Loading...'>
        <Hello />
      </Suspense>
      <Footer />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

And run build again:

○ /suspense/useSearchParams

○  (Static)   prerendered as static content
Enter fullscreen mode Exit fullscreen mode

Great! But how did it render? Here is the prerendered HTML:

<!-- 
  .next\server\app\suspense\useSearchParams.html
  cleaned up
-->

<!doctype html>
<html lang="en">
  <body>
    <header>***** header *****</header>
    <!--$!--><template data-dgst="BAILOUT_TO_CLIENT_SIDE_RENDERING"></template
    >Loading...<!--/$-->
    <footer>***** footer *****</footer>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Interesting. We now have a loading state - as expected - and some sort of template tag. When we run next start and visit the route, we see "Loading..." that gets replaced with "Hello, peter" really fast.

I'm not 100% sure how this works. My guess is that it's very similar to how client components are rendered on client side routing (when a user navigates inside your app) or during hydration. We discussed this in chapter 3 of this series.

Client-side Next requests and reads the rsc for this page. The rsc points to the client component <Hello />. Client side Next renders it (in the browser) and that rendered content gets swapped with the suspense fallback. So, in this particular case, suspense seems to set a boundary between server side and client side rendering.

Don't worry, this is an edge case. But, it is closely related to static and dynamic rendering of client components, to suspense and to partial prerendering, that is why I cover it here. Back to useSearchParams inside suspense. We can have statically rendered pages that use useSearchParams hook if we wrap the component inside suspense. This is great for performance.

Are we - partially prerendering - yet? Nope, but we really edged close here. We have a static shell that was created at build time. All components were statically prerendered expect the children of suspense. In our example, these children were rendered client-side. In PPR the rendering of a component wrapped by suspense would be done on the server.

Conclusion

At this point, we're done with the other model of caching. I specifically saved suspense and useSearchParams for last because they are a stepping stone to partial prerendering. Next was already starting to implement PPR-ish features and I wanted to highlight this.

At this point I'm going to take a small break in this series. When I'm back we'll cover the new model of caching with partial prerendering and cache components.

If you want to support my writing, you can donate with paypal.

Top comments (0)