peibolsang / octotype

My blogging platform
0 stars 0 forks source link

Experimenting with every Next.js 14 rendering option: From SSG to Partial Pre-rendering #12

Open peibolsang opened 9 months ago

peibolsang commented 9 months ago

Why am I doing this?

Early this year, I migrated Octotype (a blogging platform I created using GitHub Issues as a headless CMS) to Next.js 14. The migration experience itself was great, and working with the new App Router and React Server Components was bliss. The mental model these two new paradigms bring to the table is absolutely a step in the good direction for a better DX (and, therefore, for a better UX).

Because Octotype does not have a database and relies fully on a 3rd-party API (GitHub API), I got obsessed with fine-tuning the site's performance to the maximum. Little did I know that I got drunk with options, and I struggled to understand the differences between all of them:

  1. Static Site Generation (SSG)
  2. Incremental Site Regeneration (ISR)
  3. Traditional Server-side Rendering (SSR)
  4. Streaming
  5. Partial Pre-rendering (PPR)
  6. Bonus: Single Page Application (SPA)

After a few days, I finally figured it all out (or that's what I think ๐Ÿ˜‡), so here I am sharing what I have learned. This is a summary of all the rendering options brought to the table by Next.js 14, and during this post, I'll direct the tradeoffs of each one of them with real code examples and videos.

Note: All the examples revolve around loading a user page that contains all their blog posts. The page renders a header with the user name and uses a React Server Component to fetch and render the user's posts. I have mocked a slow 3G connection in all the videos to make conclusions more evident. Also, I used Vercel to deploy my site.

Let's get into it!

1. SSG

One of the first lessons I learned is that Next.js pre-renders your site entirely at build time by default. This means that even if you use React Server Components, the server is the CI server. It will both fetch data and render HTML on the server at build time, so the browser will just need to download static files (HTML, CSS, and Javascript).

This is what we call Static Site Generation because now your entire site is static.

Note: I believe that even if Next.js would still create static output, it would also generate a Node.js server that serves that static output. This is why, by default, SSG mode in Next.js emits a Node.js app. Or that's what Dan says.

Here are some examples. This is how my user page looks like with SSG. Nothing is magical here; it's just a normal Next.js page using a UserPosts RSC.

app/[user]/page.tsx

import { UserPosts } from '@/components/server/user-posts'

export default async function Page({params}: Props) {
  return (
      <section>
            <h1>
              {params.user}.
            </h1>
      </section>
      <UserPosts user={params.user}/>
  );
}

This is what my UserPosts RSC looks like with SSG. All it does is call a getUserPosts() function to fetch posts (yes, usingNext.js augmented fetch()) and pass the data down to a UserPostsGrid client component for final rendering.

components/server/user-posts.tsx

import { UserPostsGrid } from '@/components/client/user-posts'
import { getUserPosts } from '@/lib/api.ts'

const UserPosts = async ({ user }: Props) => {
    const posts = await getUserPosts(user)
    return (
        <div>
            {posts.length > 0 && <UserPostsGrid posts={posts}/>} 
        </div>
    )
};

export {UserPosts}

As you can see in the video, the navigation is pretty fast.

SSG Video

And this is why it's fast. At run time (time arrow), all the browser does is download static content because the actual data fetching and rendering has been done at build time. Put in a graph, SSG can be represented like this:

SSG

PROS โœ… Amazing performance with a ver low loading time.

CONS โŒ I don't have dynamic data! If the user writes a new blog post, I need to build the site (manually or force a build) so it can be included in the static output

It would be nice if I could configure my app to automatically build the site every x seconds to guarantee fresh data at a given time.

Well, enter Incremental Site Regeneration.

2. ISR

Incremental Site Regeneration allows you to configure your site to generate a static output automatically at a given configurable frequency.

And this is where I started to get drunk with options. The thing is, Next.js is super-flexible and allows you to define what is that static output. In other words, you can configure your revalidation frequency at the page, component, or the fetch request levels.

Because I am a big fan of React's component-driven model, I started putting it at the component level. I leveraged the new Next.js 14 cache() function available in the next/cache package. It's unstable, but my site is simple, and I have yet to find any problems. This implementation basically follows a stale-while-revalidate pattern, where you specify your data fetching function, tag your cache, and establish your revalidation frequency. I love this pattern because it serves you stale data for a period until it's time to serve fresh new data, which has been fetched in the background by the Next.js server.

This is what my UserPosts RSC looks like with an ISR of 1 hour. No changes are needed to my user page.tsx.

components/server/user-posts.tsx

import { UserPostsGrid } from '@/components/client/user-posts'
import { getUserPosts } from '@/lib/api.ts'
import { unstable_cache } from 'next/cache';

const revalidateUserPosts = unstable_cache(
  async(user) =>{
    return await getUserPosts(user)
  },
  ['user-posts'],
  {
    revalidate: 3600
  }
)

const UserPosts = async ({ user }: Props) => {
    const posts = await revalidateUserPosts(user)
    return (
        <div>
            {posts.length > 0 && <UserPostsGrid posts={posts}/>} 
        </div>
    )
};

export {UserPosts}

PROS โœ… Amazing performance for cache hits

MEHS ๐Ÿ”ถ Eventual fresh data

CONS โŒ Mediocre performance for cache misses โŒ Data is not fresh on demand

ISR is great for many use cases, and I actually use it in other parts of Octotype. However, if you (like me) are unwilling to pay the costs of these tradeoffs and always want fresh data at particular parts of the application, you may want to render your content dynamically on demand.

Now, enter Server-side Rendering.

3. Traditional SSR

This is equal to the traditional rendering model (PHP, JSP, ASP, etc.), where your data is fetched at the server, and your page is rendered based on that data. The client then downloads a fully operational page and hydrates it so the user can start working with it.

I won't enter into debates about if Next.js is reinventing these other technologies mentioned above. The main difference for me is the ability to combine server-side rendering with rich client interactivity following a component-based model (not MVC, there are no views here) where your UI is expressed declaratively. That mental model, for me, is a perfect fit.

Now, this is what my UserPosts RSC looks like with SSR. No changes are needed to my user page.tsx. I only had to stop using the cache() function and start using the new noStore() function. This forces my component to be completely dynamic and always fetch data on demand. Again, Next.js allows you to define this dynamism at the page, component, or fetch levels, but you already know my choice and way.

components/server/user-posts.tsx

import { UserPostsGrid } from '@/components/client/user-posts'
import { getUserPosts } from '@/lib/api.ts'
import { unstable_noStore } from 'next/cache'

const UserPosts = async ({ user }: Props) => {
    unstable_noStore();
    const posts = await getUserPosts(user)
    return (
        <div>
            {posts.length > 0 && <UserPostsGrid posts={posts}/>} 
        </div>
    )
};

export {UserPosts}

With this change, the navigation to this page isn't that fast. I am always getting fresh data at the expense of loading time. But this comes with a caveat; now, because I am using a dynamic component through the noStore() function, my whole page route becomes dynamic, as you can see in this video:

SSR Video

If we put this in a graph, this is happening.

SSR

PROS โœ… Always fresh data โœ… Better perceived performance

CONS โŒ Performance could be not great for huge data loads or slow data connection links โŒ By using a dynamic component, my whole page route becomes dynamic

I think people working at Next.js realized these tradeoffs and asked themselves: "Why do data fetching and rendering have to be sequential?" If you look at the UserPosts RSC code above, the server first fetches the posts and only then starts rendering the content. It means rendering is blocked by data fetches, but not all that content depends on data! If only there were a way to parallelize some of the things that happen at the server!

Enter Streaming.

4. Streaming

Streaming is like chopping up your data into chunks and sending them over progressively from the server to your screen. This way, you're not stuck waiting for everything to load before you can use the page. You get to see and mess around with parts of the site without hanging around for every piece of data to appear.

I like this approach because, in my mind, a chunk equals a component. This is the mental model I use, so this thing immediately clicked for me.

In my code, all I had to do to enable streaming when I deployed my site on Vercel was add Suspense boundaries around my RSC. Practically, this means adding a <Suspense> tag with a fallback, which is a component that will be rendered while my RSC gets processed (i.e., data is fetched and HTML is rendered) in parallel.

This is all it takes to enable streaming. There are no changes to my RSC, just a tiny change to my page.tsx:

app/[user]/page.tsx

import { UserPosts } from '@/components/server/user-posts'
import { UserPostsSkeleton } from '@/components/skeleton/user-posts'

export default async function Page({params}: Props) {
  return (
      <section>
            <h1>
              {params.user}.
            </h1>
      </section>
      <Suspense fallback={<UserPostsSkeleton />}>
         <UserPosts user={params.user}/>
      </Suspense>
  );
}

As you can see in this video, there is an improved real and perceived performance when loading the user posts. The page is shown faster, and a skeleton is rendered while the data is loaded and streamed in parallel. This is because the JSX inside the return statement is processed parallel to the UserPosts RSC processing (data fetching and rendering).

Streaming Video

This graph explains what's going on behind the scenes. As you can see, there is a substantial gain in performance, and the user can start interacting with the page sooner, compared to SSR:

Streaming

The skeleton is shown during a brief time interval. This is because the amount of data fetched on the server is not huge, and the connection link to the GitHub API is fast. It means that the time difference between the shell page being ready on the client (rendered on the server and then downloaded in the browser) and the RSC being ready on the client (rendered on the server and downloaded in the browser) is very small.

Streaming Skeleton

PROS โœ… Always fresh data โœ… Even better perceived performance

MEHS ๐Ÿ”ถ There is still a time gap between the user clicking a link and the shell page being displayed on the browser, although content chunks keep streaming

CONS โŒ By using a dynamic component, my whole page route becomes dynamic

Again, the folks at Next.js put their thinking hats on and asked themselves: "Why does the whole page route need to be dynamic?," "Can't we combine the best parts of SSG for the static parts and the best parts of streaming for the dynamic ones?".

Yes, we can. Enter Partial Pre-rendering

5. Partial Pre-Rendering

I'll quote the Vercel folks on this one:

When you build your application, Next.js will prerender a static shell for each page of your application, leaving holes for the dynamic content. When a user visits a page, the fast static shell is served from the end-userโ€™s nearest Edge Region, allowing the user to start consuming the page and the client and server to work in parallel. The client can start parsing scripts, stylesheets, fonts, and static markup while the server renders dynamic chunks using Reactโ€™s new streaming architecture.

In other words, the static parts of your page route are pre-rendered. This can be done at build time (SSG) or even based on revalidation frequency (ISR). Then, the dynamic parts of the same page route are streamed once the initial page shell has been loaded.

This is the only change I needed to make it work in my app on top of streaming. Nothing else, and you can see, it's still experimental.

next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    ppr: true,
  },
}

module.exports = nextConfig

Because the page shell has been pre-rendered, the first-page load is lightning fast as it is a pure static output served from a CDN. See this behavior in action in the following video:

PPR Video

Compare this graph with the others above! This is really amazing:

PPR

With PPR, the skeleton is shown during a longer interval. This is because the skeleton has been pre-rendered with the page shell and included in the static output, which means it's available at the client way sooner. This has an incredibly positive impact on perceived performance.

PPR Skeleton

PROS โœ… Amazing performance, real and perceived, with always fresh data.

CONS โŒ It's experimental. I am not using it in Octotype at this point.

Bonus: SPA

All the options described above involve some server-side rendering in one form or another with Next.js. These are all default features of the framework, which does not mean you can't do a SPA with Next.js. You can, but it isn't obvious, as it requires some additional work outside the responsibilities of the framework.

Let me explain.

First, you must translate your RSC into a client component and fetch data using React classic hooks, useState and useEffect. Nothing special here.

components/client/user-posts.tsx

import React, { useEffect, useState } from 'react';
import { UserPostsGrid } from '@/components/client/user-posts';
import { getUserPosts } from '@/lib/api.ts';

const UserPosts = ({ user }) => {
    const [posts, setPosts] = useState([]);

    useEffect(() => {
        const fetchPosts = async () => {
            const fetchedPosts = await getUserPosts(user);
            setPosts(fetchedPosts);
        };
        fetchPosts();
    }, [user]);

    return (
        <div>
            {posts.length > 0 && <UserPostsGrid posts={posts} />}
        </div>
    );
};

export { UserPosts };

Second, you can leave your default routing untouched using the App Router, but you need to export your application as an MPA so it can be hosted in a non-node environment, such as Amazon S3. This means you must build your application using the command next build && next export. (Remember, by default, Next.js SSG generates a node app).

Third and final (and this is not trivial) because you now have an HTML file per route instead of a single index.html, you need to write some custom logic to map your site routes to these different files. This can be done via a script in nginx or an Edge Lambda in AWS CloudFront, but you must do it yourself.

But hey, bear with me. Dan explains this better than I do and even includes some code examples on how to do it!

This is what SPA processing looks like in a graph. As you can see, the client download time is still okay. Unlike other SPAs, Next.js enables a better SPA as the client does not have to download an entire unique bundle containing the entire application. However, fetching data from the client is slower than on the server (.... at least theoretically!). In other words, it's worth analyzing the performance differences between Next.js SPA and SSR methods in detail, as differences can be minimal.

SPA

Summary

While I love frameworks that give you options, I got drunk with them. Documentation is still getting there, but the Next.js team is doing a great job creating educational material as some of these features are adopted and refined. Consequently, we are in a stage where much of the learning comes through rolling up your sleeves and start building, which is a good method anyway. And if you build in public and share your knowledge, you're contributing to improving all these features.

What rendering option is better? It all depends on your application and how you manage data. For example, it wouldn't make sense always to get fresh data for a blog post because its content does not change frequently. This would make ISR a good option for this use case. On the contrary, switch to SSR or Streaming if you have a page that renders content based on data that changes very often and you always want to show it fresh on demand. Lastly, if you have a mixed page with very clear static parts that don't change often, mixed with dynamic parts that change frequently, that makes it perfect for PPR. And, to be honest, this method could even become the default for many pages on many sites out there. And that's big.

The following picture represents an overall summary of all these rendering methods.

All

peibolsang commented 8 months ago

I kept experimenting, and I came to the following conclusions.

Partial Pre-rendering

Cache

SSG and revalidation

peibolsang commented 8 months ago

Update about my problems with unstable_cache above!

From Lee Robinson:

The header being a MISS means the entire page is not cached. Thereโ€™s some dynamic code. Itโ€™s possible you have both a cached value using unstable_cache() and other dynamic code like cookies().

However, I don't have any type of dynamicity in my layout or page (nothing like force-dynamic, no-cache, noStore(), cookies(), or headers()). All I have is an innocent fetch() call wrapped by unstable_cache().

But, because I am an idiot, my page is a dynamic route (e.g., app/[user]); therefore, it can't be cached in the CDN. If I want this dynamic route to be cached entirely, I need to put an export const dynamic="force static", which is what I ended up doing anyway.

All this means the cache MISS in x-vercel-cache header I have been experiencing is a CDN Cache MISS for the page, not a Next.js Server cache MISS for the data. I know that unstable_cache actually works and serves cached data, although the entire page is not served from the CDN because it's a dynamic route.

This also means I can use edge runtime for dynamic routes if I want! I can't use edge for the landing page because it's not a dynamic route and because of the issue above, we'll get a cache MISS always.

peibolsang commented 8 months ago

All the findings above prompt the following question: why don't we mark all our pages (landing and dynamic routes) as static pages to be served from the CDN with export const dynamic="force static" to get the optimum performance?

Then, we can revalidate tags/pages when data mutations happen.

  1. If data mutations happen outside our system, we can expose a Webhook that forces the revalidation through revalidatePath() or revalidateTag(), so users will get fresh data after a mutation.
  2. If data mutations happen in our system, we can implement server actions that force the revalidation, too, so users will get fresh data after a mutation.

I tested this, and it works. For use case number 2 (i.e., data mutations happen in your system), the data revalidation itself is fast, but:

What am I missing?

Note: The only disadvantage of force-static is that it does not work with the edge because this issue has not been solved: https://github.com/vercel/next.js/issues/42112