Next.js, a framework for server generated/rendered/optimized React applications

by
Tags: , , ,
Category:

In this series of articles, I'm going to discuss Next.js, a framework for serving React-based single-page applications. I'll show you why people are using it as a way to accelerate the speed of launching React applications, improve search indexing, and pre-rendering of static content. I'll also compare and contrast it to other similar platforms such as Gatsby and Nuxt.

The articles assume a basic understanding of React. The samples in this article are written in TypeScript, but TypeScript is not needed to write Next.js code.

What is Next.js?

Next.js is a framework for developing and hosting React applications. Its key features include additional methods and hooks for React components, and exposing the application as a series of pages, each of which can be fetched with pre-rendered or server-side generation of data before being delivered to the browser.

Component APIs

For components, there are three main additional Next.js functions available, all exported from the same component as named exports:

  • getServerSideProps – Allows for request-time fetching of data on a NodeJS server before downloading the component and data to the client

  • getStaticProps – Allows for pre-rendering of pages at build-time, building up properties and fetching and populating static data

  • getStaticPaths – Allows for Server-side pre-rendering of dynamically generated pages based on content

The Next.js Router

Next.js provides a src/pages folder, and uses its own router which follows the page structure to expose routes. For example:

src/pages
    ├── index.tsx
    └── posts
        ├── [id]
        │      └── index.tsx
        └── index.tsx

The structure above automatically provides the following routes:

  • / – serving pages/index.tsx
  • /posts – serving pages/posts/index.tsx
  • /posts/:id – serving pages/posts/[id]/index.tsx

Each page can be fetched by its URL, which sends the page and then boots the React application once fetched, downloading the other content in the background.

Bootstrapping a typical React Application

Given a React application developed with create-react-app, a build usually results in several JavaScript files, delivered at the end of the <body> tag:

  • An inlined chunk of JS code to help bootstrap JS modules
  • a JS chunk file including the React framework and other libraries
  • a JS chunk file representing your application code

CSS assets are deployed as links in the HTML head.

You have many options to improve this build process, as it's just the default one given by the create-react-app built-in Webpack build. However, out of the box the build simply splits up application code and framework code, minifies it, and attaches it to the document. All scripts load in a normal fashion.

For very small applications, this is perfect. But when applications become larger, and are broken up into smaller modules, you may wish to break it apart further by modifying the chunks produced by the Webpack build process.

Another thing to consider is the initial parsing time taken when loading the React application. Look at this screenshot:

React App Loading Times

That gap of time, rather small in this toy application, is the time taken to bootstrap the React app, router, Redux, components, etc.

Dynamically loading components in React are made possible with the relatively new import() feature in ECMAScript and by using React.lazy and React.suspense, which can also be attached to Routes as per this guide.

However, all of this work is manual and requires direct interaction with the framework.

Bootstrapping a Next.js Application with Async script loading

One of the reasons Next.js accelerates your application is the way it breaks up the assets, such as scripts, css files, images, and its own framework. For small React-based applications, you won't notice a huge difference, but for large-scale applications with lots of pages and content, the time to first paint and ultimately to reactivity will take a hit as the number of components scales up.

Next.js provides a modular, asynchronous loading system, which helps bootstrap your initial page as quickly as possible, while deferring the load of other portions of the application until after the initial components are loaded and started.

As an illustration, let's see how the index.html file served by a Next.js application delivers the application assets. Here

The scripts and assets are loaded in several chunks:

  • Stylesheets (global, as well as imported by components)
  • Polyfills (for browser compatibility patches)
  • A dynamic loader script
  • The Next.JS framework
  • The main JS code

When serving a Next.js application, you'll typically see scripts added to the head in two stages, first to bootstrap the application shell, and then later to preload others pieces of the application asynchronously.

First, the initial chunks load: the globally applied CSS styles:

  <link rel="preload" href="/_next/static/css/3617bc4959fd4041.css" as="style">
  <link rel="stylesheet" href="/_next/static/css/3617bc4959fd4041.css" data-n-g="">
  <link rel="preload" href="/_next/static/css/ef46db3751d8e999.css" as="style">
  <link rel="stylesheet" href="/_next/static/css/ef46db3751d8e999.css" data-n-p="">
  <noscript data-n-css=""></noscript>

Then, the initial application bootstrap and request for application assets, broken down by page,
is sent. Note the defer attribute on the script tags, which allows them to be loaded after the initial page rendering has begin, and how we have more, smaller assets to load in parallel:

<!-- polyfills for browser compatibility -->
<script 
    defer
    nomodule
    src="/_next/static/chunks/
         polyfills-a40ef1678bae11e696dba45124eadd70.js">
</script>

 <!-- Webpack code for dynamic asset loading -->
<script
   src="/_next/static/chunks/
        webpack-514908bffb652963.js" defer>
</script>

<!-- The React library and other npm libraries - framework script -->
<script
  src="/_next/static/chunks/
      framework-91d7f78b5b4003c8.js" defer>
</script>

<!-- the main application bootstrap code -->
<script
  src="/_next/static/chunks/
       main-3ff41d7419af9144.js" defer>
</script>

<!-- now the pages themselves, exposed as separate js modules -->

<!-- the layout template -->
<script 
  src="/_next/static/chunks/pages/_app-50bccf0282704f01.js" defer>
</script>

<!-- the index page -->
<script 
  src="/_next/static/chunks/pages/index-4f34c16c5a215ea3.js" defer>
</script>

<!-- Configuring the app as a Progressive Web Application -->
<script 
  src="/_next/static/DUAfRuSjugusb5FPn5_g7/_buildManifest.js" defer>
</script>

<!-- Configuring the server-side generated pages (if any) to load in the background -->
<script 
  src="/_next/static/DUAfRuSjugusb5FPn5_g7/_ssgManifest.js" defer>
</script>

<!-- configuring other middleware content (we have none here) for background loading -->
<script 
  src="/_next/static/DUAfRuSjugusb5FPn5_g7/_middlewareManifest.js" defer>
</script>

<!-- begin prefetching... -->
<link as="script" 
  rel="prefetch" 
  href="/_next/static/chunks/pages/posts-4388e5526d2af98b.js">

After this, all of the other assets requested by the client begin loading in the background.

Load timing

Let's take a look at the loading timing for the Next.js application. According to Chrome Network Developer Tools, we see this:

Next App Loading Times

I've highlighted the first scripts that boot the application, which all scatter load in about 15-20 milliseconds. The rest load a bit later.

Statically generated pages

Consider an application that provides some of its content statically, such as a CMS-driven blog. I've ported part of my old website content into GraphCMS and used a Next.js-driven React component to dynamically generate the listing page, and each individual route.

At build time, the GraphCMS database is queried via GraphQL for a list of its blog posts. Here is the query:

query getPosts {
  blogPosts {
    id
    title
    description
    postSlug
    postContent
    postTime
  }
}

I've used GraphQL Request to access GraphCMS and perform this query. The API call is fetched from my blog post listing component, via the getStaticProps function provided by Next.js:

export async function getStaticProps(context: any) {
    const posts = await getPosts();
    return { props: { posts } };
}

Simply exposing this function by name in a component executes it on the server at build time, and packages the content as a static asset.

The functional component that uses this method then accesses the props as normal:

export default function Posts(props: any) {
    const { posts } = props;
    const cards = posts.blogPosts.map((post: any, index: number) => {
      ... // add a card with post data
    }
    return (
      ...
      { cards } 
      ...
    );
}

Dynamic Routes and Static Rendering Together

Suppose I want to provide a route to render an individual blog post, but I don't want to request the data each time from the CMS. Rather, I want it all pre-rendered as static assets, something that can be deployed on a CDN. First, we can create a path in pages to represent the dynamic route:

// at pages/posts/[id]/index.tsx 

export default function Post(request: any) {
  const { post } = request;
   return (
     <section className={styles.blogPost}>
       <h2>{post.title}</h2>
       <hr/>
       <div
           className="postBody"
           dangerouslySetInnerHTML={{__html: post.postContent}}>
       </div>
     </section>
   );
};

Pretty normal-looking component (ok, I'm rendering HTML content in the DIV, but whatever…) I'm also using CSS module support, baked in to Next.js, by importing the page-specific styles with:

import styles from '../posts.module.css';

Now, to determine the pages to pre-render, I have to provide the getStaticPaths method. This method is run first, and returns a property, paths, which is used to then call getStaticProps on each new page as it is pre-rendered:

export async function getStaticPaths() {
  const postIds = await getPostKeys();
  if (postIds.blogPosts && postIds.blogPosts.length > 0) {
    const paths = postIds.blogPosts.map((p: any) => ({ params: { id: p.postSlug } }));
    return {
      paths, fallback: false
    };
  } else {
    throw new Error('posts not found');
  }
}

Note, the GraphQL query issued here just queries the ID of each post, so it can be fetched by the next method to get the full post details.

The params prop is a well-known prop, used to send the route params to each rendered component. Only serializable, simple properties can be passed to the params property here, so this is just used as a collection to hydrate the pages themselves.

Let's look at how we then use this component's getStaticProps method to actually hydrate each page:

export async function getStaticProps(context: any) {
  const {params} = context;
   const post = await getPost(params.id);
   return {
      props: {
         post
      }
   };
}

The query in getPost fetches all fields for the blog post itself, which is then used by the functional component above to render all of the properties.

Pre-rendering at build time – what it looks like

Flush with your new build-time generation code, you issue the build command:

npm run build

If everything goes well, Next.js connects to your datasources and generates your code. Here's my build output:

...
info  - Generating static pages (29/29)
info  - Finalizing page optimization  

Page                                                         Size     First Load JS
┌ ○ /                                                        338 B          73.4 kB
├   └ css/ef46db3751d8e999.css                               20 B
├   /_app                                                    0 B            73.1 kB
├ ○ /404                                                     194 B          73.3 kB
├ λ /api/hello                                               0 B            73.1 kB
├ ● /posts (720 ms)                                          524 B          73.6 kB
└ ● /posts/[id] (15433 ms)                                   472 B          73.5 kB
    ├ /posts/acb0d78d-e8e9-ac99-c0da-4a9e02f8ab7c (1893 ms)
    ├ /posts/2364ab4d-dbad-0d50-1ba5-cfaf1f949fc9 (1674 ms)
    ├ /posts/4c5fb7b3-5cd4-d5aa-b381-9135d5b49cdf (1460 ms)
    ├ /posts/f5677c60-7c83-6cc2-3981-1bb9e76d4c2b (809 ms)
    ├ /posts/f74711f9-fe02-e419-461a-67fbcd99c092 (799 ms)
    ├ /posts/b98b5984-05f1-08ee-eaa6-98f98f192a0c (784 ms)
    ├ /posts/686d2f27-8d89-0d96-8d75-0d04f5d9a3cd (715 ms)
    └ [+18 more paths] (avg 406 ms)
+ First Load JS shared by all                                73.1 kB
  ├ chunks/framework-91d7f78b5b4003c8.js                     42 kB
  ├ chunks/main-3ff41d7419af9144.js                          27.8 kB
  ├ chunks/pages/_app-50bccf0282704f01.js                    2.43 kB
  ├ chunks/webpack-514908bffb652963.js                       770 B
  └ css/3617bc4959fd4041.css                                 212 B

λ  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)
○  (Static)  automatically rendered as static HTML (uses no initial props)
●  (SSG)     automatically generated as static HTML + JSON (uses getStaticProps)

Very useful output, including just how many of your pages are pre-generated, servers-side generated, or server-side rendered.

You'll see the pre-rendered pages in the .next directory, under server/pages/posts:

Server-rendered Posts

You can serve the application using the provided startup script:

npm run start

And that's it.

Providing the right APIs for your pre-rendering process

You can probably tell that build time and efficiency can be an issue here. For example, querying the GraphQL collection and requesting all of the post data in both the listing and detail pages is expensive, but only at build time.

Given 50 posts, we'd have to call:

  • One query fetching enough detail to populate the listing page for all 50 posts.
  • One query fetching the ids of each post for generating the routes in the blog post detail page
  • One query for each blog post (50 more queries) to populate all of the blog post page contens.

So, that's 52 queries, but again, only at build time, and with zero effect on production.

You can also read into this that huge content farms may not be efficient to generate at build time (can you even hold all of the content to fetch the data without running out of memory?)

So, the devil is in the details. You don't necessarily want to render ALL of the routes, either. Maybe only the most popular ones, letting the rest load dynamicaly, either on the client with a normal component, or even using getServerSideProps.

We'll explore some of these techniques, tips, pitfalls and other considerations in upcoming articles.

Wrap-up

I've provided my (admittedly lame) "rimple-blog" sample code on GitHub, just so you can get an idea of how this works.

Hopefully this whets your appetite for future articles.