NextJS Page Rendering Options
9 min read
One amazing feature about the NextJS framework is all of the various options one has around how to render a page, which can be determined on a page-by-page basis. The downside to this is that it can be quite overwhelming to NextJS newcomers. I therefore thought I’d write this blog post to try to summarise the various rendering options and why one might choose one over another.
Note that the Vercel team are constantly improving the framework so be sure to check the docs for the latest updates!
Pre-rendering - what is it?
- Plain React apps have no pre-rendering. This means nothing is seen on initial load as nothing is immediately rendered. We need to wait for the JS to load, before hydration can happen (when React components are initialised and the app becomes interactive).
- NextJS uses pre-rendering, meaning the server serves initial HTML which can be displayed on initial load (faster time to first byte i.e. faster performance). The JS is then loaded and hydration occurs.
Static generation
- This is when HTML is generated at build-time and reused for every request.
- This is done using the
next build
command, which happens prior to any request being made i.e. at build time. - In practice, if we deployed on Vercel, we would have deployed this file to Vercel’s servers, which caches it so it can be reused for each request (on the Vercel Edge Network).
- Note: You might see references to SSG. This means Static Site Generation.
Types of static generation
There are 2 types of static generation: (i) without data and; (ii) with data.
If there is no data required, you can just code your components and run
next build
.If data is required, use the
getStaticProps
method to fetch the data you need at build time (e.g. from an API or internal data set). This data can then be passed into your component asprops
.function Store({ products }) { return ( <ul> {products.map((product) => ( <li>product.name</li> ))} </ul> ) } export async function getStaticProps(context) { const res = await fetch(`https://${yourEndpoint}`) const data = await res.json() return { props: { products: data.products, }, } } export default Store
Pre-rendering page paths during static generation
You can pre-render pages with paths that depend on external data. e.g. if you have a blog with lots of different posts. This is done with
getStaticPaths
.// pages/posts/[id].js export async function getStaticPaths() { const res = await fetch(`https://${yourEndpoint}`) const posts = await res.json() const paths = posts.map((post) => { params: { id: post } // Note: id name here must match the [id] param in page name }) return { paths, fallback: false, // Other options: true, 'blocking' } }
If
fallback: false
is set, a 404 page is shown if a user goes to a page whose path doesn’t exit.fallback: true
is useful in cases where you only want to generate a subset of pages during build time (e.g. if you have a lot of pages and don’t want to bog down your build time). If a user requests a page that wasn’t statically generated, they’ll first be shown a fallback version of the page (e.g. with loading spinners and a skeleton layout), whilst a request to statically generate the page (includinggetStaticProps
) is run in the background. The page will automatically be rendered with the content once the request is complete.fallback: blocking
is somewhat similar totrue
except that nothing is shown to the user while the HTML generation is happening (similar to SSR which I’ll explain below). Once generated, this page is cached for future requests so that this only happens once per path.
More information in the official docs here.
Incremental Static Regeneration (ISR)
Similar to static generation with data above, but instead of the data fetching happening at build time, it happens at request time instead.
function Store({ products }) { return ( <ul> {products.map((product) => ( <li>product.name</li> ))} </ul> ) } export async function getStaticProps(context) { const res = await fetch(`https://${yourEndpoint}`) const data = await res.json() return { props: { products: data.products, }, revalidate: 60, // <-- this is the key addition! Note: in seconds. } } export default Store
Setting
revalidate: 60
means that we’re saying, “After 60 seconds, this content is stale”. Note this sets the “Cache-Control” HTTP header, stale-while-revalidate response.If a user come to the site within the first 60 seconds, they will see the static HTML generated at build time.
After 60 seconds, any user that comes to the page will see stale content. This kicks off a request in the background however, where we call
getStaticProps
. Assuming a successful response, this invalidates the cache and updates it with the new content. The next user that comes to the site will now see this updated content.ISR allows us to somewhat combine static generation at build time and static generation at request time, which means not having to constantly rebuild the whole application whenever there’s a page update.
On-demand incremental static regeneration
- Available from NextJS v12.2.0.
- Allows us to manually purge the NextJS cache, e.g. if CMS content is updated.
revalidate
does not need to be set. If omitted, this defaults totrue
, and the page is regenerated when therevalidate()
function is called.- This page will be stored in a global cache, available to all users (compared to standard ISR, where the page is only cached on servers closest to the user).
Server-side rendering (SSR)
HTML is generated on every request. Used when you have very dynamic / personalised content, that needs to be fresh at every request, or pages requiring authentication.
The request for data is done in a blocking manner so the user doesn’t see any page content until the request is complete. This is done via the
getServerSideProps
function (fairly similar in implementation togetStaticProps
).function Store({ products }) { return ( <ul> {products.map((product) => ( <li>product.name</li> ))} </ul> ) } export async function getServerSideProps(context) { const res = await fetch(`https://${yourEndpoint}`) const data = await res.json() return { props: { products: data.products, }, } } export default Store
Deciding when to use SSR vs. ISR can be tricky.
- Note that ISR with
fallback: ‘blocking’
is effectively like SSR so think about whether you really want this. - SSR is generally better if you have edge cases that you need to deal with server-side, as it provides more flexibility than ISR e.g. having to deal with data from multiple domains, depending on request headers. This is because SSR gives you access to the request object, whereas ISR does not.
- Another use case is when you need to serve personalised content but don’t want to use CSR (see below).
- SSR will result in the longest load time since these pages will not be cached. Think about how to avoid long
getServerSideProps
as users will not see anything until this is complete. Another thing to think about is whether to move your database to the same region as the serverless functions (to reduce connection times).
- Note that ISR with
If deploying your NextJS site on Vercel, you can server-render using Node.js runtime serverless functions (default option), or use the Edge runtime, running Edge functions.
- Using Edge functions allows you to stream in page content incrementally, thus reducing time to first contentful paint.
Client-side rendering (CSR)
Used when we want static generation without data, but want to fetch data on the client-side.
A practical example might be:
- During build time, we generate a loading skeleton layout.
- This means that when a user lands on the page, this is what they’ll immediately see, while waiting for the JavaScript to load.
- Once the JS finishes loading client-side, we can then fetch external data and populate the page with the response data (assuming it’s successful).
Vercel has a custom hook to make this easy called
useSWR
.import useSWR from 'swr' function Dashboard() { const { data, error } = useSWR('/api/user/dashboard', fetcher) if (error) return <div>Something went wrong</div> if (!data) return <div>Loading</div> return <div>{data.name}'s Dashboard</div> }
<div>Loading</div>
This is first shown before the JS completes its loading.- Once the JS is loaded, it runs the
useSWR('/api/user/dashboard', fetcher)
command. - If this is successful, we then show
<div>{data.name}'s Dashboard</div>
.
This is great for improving performance by decreasing time to first byte (TTFB). It’s especially useful if you need to make a lot of requests to get the content you need, where SSR could be detrimental due to requests being blocking until complete.
Conclusion
- NextJS is a hybrid framework that allows us to choose which rendering strategy we want on a per-page basis.
- Examples of when you might want to use certain strategies:
- A standard blog that pulls posts from a headless CMS - static generation with data.
- A blog that has millions of posts, pulled from a headless CMS - static generation with data, but with
fallback: true
assuming you only generate a subset of pages during build time. - A personalised page that only has one section requiring a fast, but complex API call - server side rendering.
- An authenticated user’s data dashboard with lots of different charts - client side rendering.
- Landing page which is really important for SEO - ensure the fastest time to first byte by not using SSR. Use static generation without data is there is no dynamic data.
- Authenticated data for internal teams - server side rendering (because you don’t particularly care about TTFB anyway).