DEV Community

Peter Jacxsens
Peter Jacxsens

Posted on

Static and dynamic rendering of client components

In the previous chapter, we looked into static and dynamic rendering. Rendering happens at route level and means that we run the functional components and use their return values. Each route gets rendered into a bunch of files (js, css, icons, images, fonts,...) but we're interested in these 2:

  • HTML file: <div>Hello world</div>, served at initial load.
  • rsc payload file: { "div", children: "Hello world" }, served at client side navigation.

Both static and dynamic rendering create these 2 files. Static rendering does it at build time, dynamic rendering does it at request time - using "fresh" data.

But, this process only describes routes made of server components. Rendering routes with client components works differently.

Client components

Client components are components that:

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

Any component that is not a client component is a server component in Next.

But, here's a question: Can a client component be statically prerendered?

From earlier, we know that dynamic rendering happens when a route has dynamic elements like headers, cookies, searchParams, connection, draftMode or dynamic fetch. Notice how the definition of dynamic rendering does NOT overlap with the definition of client components. So, yes? They can be rendered statically?

But there remains some confusion. How can a component that uses browser API's like localStorage be prerendered statically in an environment where window and localStorage don't exist? That is what we will explore in this chapter.

A simple example

Let's start with an example once again and just observe what happens. Note: the examples are available on github.

// components/client/ClientWithState.tsx

'use client';

import { useState, useEffect } from 'react';

export default function ClientWithState() {
  const [count, setCount] = useState(100);
  useEffect(() => {
    document.title = 'Updated title';
  }, []);

  const handler = () => {
    setCount((prev) => prev + 1);
  };

  return (
    <>
      <div>Count is {count}</div>
      <button
        onClick={handler}
        className='bg-lime-500 rounded px-2 py-1 text-black'
      >
        add 1
      </button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

It's a simple counter component. We have state initiated with 100. The count is displayed and there's a button to increment the count. I also run useEffect to update the page title. We load <ClientWithState /> in a route with some server components we used in previous examples:

// app/static-client/page.tsx
export default async function Page() {
  return (
    <>
      <ClientWithState />
      <Header />
      <Main />
      <Footer />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode
export default function Header() {
  return <header>***** Header *****</header>;
}

export default function Main() {
  return <main>***** Main *****</main>;
}

export default function Footer() {
  return <footer>***** Footer *****</footer>;
}
Enter fullscreen mode Exit fullscreen mode
next build
Enter fullscreen mode Exit fullscreen mode

The build confirms that /static-client route was prerendered as static content. So, it's possible to render client components statically. But let's take a look at the prerendered HTML and rsc files.

HTML

If we remove all the scripts, clean out the header and run prettier, this is what we get:

<!-- .next/server/app/static-client.html -->
<!doctype html>
<html lang="en">
  <head>
    <title>Caching in NextJS: examples</title>
  </head>
  <body>
    <div>
      Count is
      <!-- -->100
    </div>
    <button class="bg-lime-500 rounded px-2 py-1 text-black">add 1</button>
    <header>***** header *****</header>
    <main>***** main *****</main>
    <footer>***** footer *****</footer>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode
  1. At prerender, Next was able to initiate state and set count: <div>Count is 100</div>.
  2. The button was rendered as HTML. No inline event handler to be seen. So button doesn't do anything.
  3. The title tag is not "Updated title". Implying useEffect didn't run.

The big thing to notice here is that a client component can be rendered outside of the browser, during build time. The useState hook behaved as expected. This goes for most hooks but I'll show some more examples later.

rsc payload

We already know that it's hard to read rsc. It's not meant to be read but I want to show it at least once more:

// .next/server/app/static-client.rsc
// edited

d: (I[(67655, ['/_next/static/chunks/d975380482ceac7a.js'], 'default')],
  [
    (['$', '$Ld', null, {}],
    ['$', 'header', null, { children: '***** header *****' }],
    ['$', 'main', null, { children: '***** main *****' }],
    ['$', 'footer', null, { children: '***** footer *****' }]),
  ]);
Enter fullscreen mode Exit fullscreen mode

We can make out an array with the 3 server components. They are the rendered result of the server components: HTML elements/nodes represented as JavaScript objects. (Don't worry, you don't need to know this)

But there's a fourth element, the first element in the array. That is a placeholder for the client component. It is linked via ID to the relative path to the js file on the first line. This path refers to another part of the Next build folder: .next/static. This folder is part of the js client bundle and will actually get send over to the browser. Here's that file:

client component chunk

Looks kinda familiar? That is our <ClientState /> component converted into plain js. (Still, no worries, you don't need to know this) But - and this is important - it's the actual component, not the rendered result of the component.

So, the rsc payload contains the rendered server components but not the rendered client component. Next did reserve a slot for the client component, a placeholder. This placeholder refers to a file, a js chunk that contains the actual client component, not the rendered result. This chunk was created at build time and is part of the js client bundle that the client (the browser) will download.

Quick recap

We ran next build and examined a route containing a client component. The route was prerendered statically in the Next build folder: an HTML and a rsc file.

The HTML file contained the rendered result (elements or nodes) of the server and client components. However, the client component was non interactive. The button didn't have an event listener.

The rsc file only contained rendered results of the server components. There was an open spot reserved for the client component, a placeholder. This placeholder referenced a static file, also in the Next build folder, that contained the actual client component, not a rendered result of said component.

The theory

In the previous chapter, we mentioned that these prerended route files have a different goal:

  • The HTML is served on first load: when first entering the app.
  • The rsc is served on client-side routing: internally navigating the app.

This is actually not 100% correct. When the route contains client components, the rsc file will be served both on first load and on client-side routing. To explain why, we need to understand:

  1. How Next works in the browser on first load.
  2. How Next handles route changes in the browser.

1. How a browser mounts Next on initial load

On initial load, after a request, the server sends over the HTML, rsc and a whole bunch of JavaScript (the js client bundle). The browser immediately displays the static HTML file (no event listeners or interactivity). Once the client bundle is loaded it will start up Next/React.

Client side, in the browser, Next/React:

  • Reads the rsc payload.
  • Hydrates:
    • Grabs all the client components referenced in rsc.
    • Virtually renders these client components (creates virtual DOM).
    • "Syncs" the actual DOM (created from HTML file) with the virtual DOM by attaching event listeners and initializing state.
  • Runs useEffect.

This matches what we observed in our example. The important part is that both client and server components render once on the server. The client components are rendered again in the browser.

2. How Next handles client side routing

On client-side routing, Next will request the rsc payload for this new route from the server. Next then gathers all the changes it needs to make and swap it with the old content in a single paint. What are these changes?

  • The rsc file contains all the rendered server components. We saw this in the previous chapter: js objects that describe HTML elements. Next will convert (not render) all the rsc payload for server components into elements or nodes and insert them directly inside the DOM. No virtual DOM for server components.
  • The rsc file contains all references to client components. Next will render these client components virtually (virtual DOM) and then directly mount them into the DOM. There is no hydration here because there is no prerendered HTML for client components. That only happens once, on first load.

So, prerendered server components from the rsc payload and client side rendered client components are combined and replace the old content in one paint.

Some notes here. This is very complex, there are so many moving bits:

  • The rsc will probably get prefetched.
  • A loading.js file or suspense component may interfere in this flow.
  • Router cache also lives here. (See later)
  • Shared components like layout.tsx will be excluded from the page update.
  • ...

So, on client side routing, the prerendered server components are fetched from the server. The client components are rendered client-side. Both are mounted in the DOM and replace the content from the old route.

Quick side note: static rendering prerenders the HTML and rsc at build time. Dynamic rendering renders the HTML and rsc at request time. Everything else is the same. Wether the HTML and rsc are fresh or old doesn't matter to the browser.

Rendering client components

We started this chapter with a question: Can a client component be statically prerendered?. By now you should know that it can. Client components mostly run in the client. That is why we call them client components. Server components never run on the client. Only the rendered result is sent to the client. That is why we call them server components.

Client components also render once on the server during build or request time. From an earlier example - <ClientState /> - we saw that Next is able to initiate state during static or dynamic rendering - outside of the browser. This is true for most hooks. Next will be able to prerender them. There is one weird one: useSearchParams, but we will look at that one later.

Aside from hooks and interactivity, components that use browser API's also need to be client components. This may seem a bit tricky at first, browser API's only live in the browser. So how can they be statically prerendered outside the browser? They can't.

Let's say we wanted to initialize state with a value from localStorage then this won't work (in Next):

// ❌ don't do this

export default function ClientWithBrowserAPI() {
  function initiateState() {
    const lscount = localStorage.getItem('lscount');
    return lscount ? Number(lscount) : 0;
  }
  const [count, setCount] = useState(initiateState);
  return <div>Count is {count}</div>;
}
Enter fullscreen mode Exit fullscreen mode

This would error both in dev mode (⨯ ReferenceError: localStorage is not defined) and at build time (ReferenceError: localStorage is not defined). We know why. At build time (not in the browser), there is no window or window.localStorage. And that's the error. Correct approach would be:

// components/client/ClientWithBrowserAPI.rsc
'use client';

export default function ClientWithBrowserAPI() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const lsCount = localStorage.getItem('lsCount');
    if (lsCount !== null) {
      setCount(Number(lsCount));
    }
  }, []);
  return <div>Count is {count}</div>;
}
Enter fullscreen mode Exit fullscreen mode

You probably knew this already. But now you understand why: useEffect doesn't run on static or dynamic rendering. That means that the prerendered HTML would be this: <div>count is 0</div>. useEffect only runs after the client component is rendered inside the browser. Inside the browser, useEffect has access to window.localStorage.

And that is how all browser API's should be used in Next: either inside useEffect or inside a handler function that only runs after an event.

So, yes, client components that use browser API's can be statically or dynamically rendered in Next. I hope you have a better feel for this now.

Why

We have thus far skipped the why. Why do we need static rendering? What's the point. We know what it is, but why do we need it? Gemini came up with a pretty good answer:

⚡ Blazing Speed (Performance): Because the HTML is pre-generated, it can be stored on a CDN (Content Delivery Network). This means when a user visits your site, the file is sent from a server physically close to them. There is no waiting for database queries or server-side logic.

🔍 Superior SEO: Search engine crawlers love static HTML. Since the content is already there the moment the bot "looks" at the page (no waiting for JavaScript to load), your site is much easier to index, which can lead to higher search rankings.

🛡️ Enhanced Security: Static files are just text. By reducing the amount of "live" code running on a server for every request, you significantly shrink the "attack surface" for hackers. There's no database to inject or server process to exploit on that specific request.

💸 Lower Costs & Scalability: Serving static files requires very little "compute" power. Your server won't break a sweat (or your budget) if you suddenly get 100,000 visitors, because you're just handing out pre-made files rather than building 100,000 pages from scratch.

I'm gonna leave it at that.

Closing

We now understand static and dynamic rendering of server and client components. Static rendering is what creates the full route cache.

There is something we left out. A hybrid in between static and dynamic rendering: incremental static regeneration. We will cover that in a later chapter but first, let's take a good and long look at data cache.

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

Top comments (0)