Product

Increasing the Performance of Dynamic Next.JS Websites

by Guido Maliandi on November 6th, 2017

Increasing the Performance of Dynamic Next.JS Websites cover

One of the most obvious signs that a site is made with Next.JS is that you load the page, you click a link to another section and it loads instantly. That’s because when a page is loaded, Next.JS will download in the background all the other pages linked with tags. This works great for static pages, but as soon as you have to fetch some data in the getInitialProps, loading that page will take time even if it the page code is prefetched. And what is worse — unless you explicitly add a loading indicator, it will usually take time with no indication in the UI! Prefetching and caching data is not trivial — for example, it could be wrong to serve a slightly outdated version of certain information. Also, different data could have different caching rules. Here at Scale we decided to implement a lightweight caching and data prefetching strategy, which allows having this instant navigation experience without compromising data freshness.

Caching in getInitialProps

On certain parts of our site, we know it is OK to show data that is a few minutes old. For those cases, we implemented a simple caching strategy that stores data with a 5 minute TTL. The implementation uses an abstraction around the fetch api that we called cached-json-fetch:

1
2import lscache from 'lscache';
3import fetch from 'isomorphic-fetch';
4
5const TTL_MINUTES = 5;
6
7export default async function(url, options) {
8// We don't cache anything when server-side rendering.
9// That way if users refresh the page they always get fresh data.
10if (typeof window === 'undefined') {
11return fetch(url, options).then(response => response.json());
12}
13
14let cachedResponse = lscache.get(url);
15
16// If there is no cached response,
17// do the actual call and store the response
18if (cachedResponse === null) {
19cachedResponse = await fetch(url, options)
20.then(response => response.json());
21lscache.set(url, cachedResponse, TTL_MINUTES);
22}
23
24return cachedResponse;
25}
26
27export function overrideCache(key, val) {
28lscache.set(key, val, TTL_MINUTES);
29}

We then use this in the getInitialProps of our pages:

1
2import React from 'react';
3import cachedFetch, { overrideCache } from 'lib/cached-json-fetch';
4
5const SOME_DATA_URL = '/some_data';
6
7export default class SomePage extends React.Component {
8static async getInitialProps(ctx) {
9const someData = await cachedFetch(SOME_DATA_URL);
10const isServerRendered = !!ctx.req;
11return { someData, isServerRendered };
12}
13
14componentDidMount() {
15// When the page is server-rendered,
16// we override the value in the client cache
17if (this.props.isServerRendered) {
18overrideCache(SOME_DATA_URL, this.props.someData);
19}
20}
21}

Now, when we client-navigate to this page, it will check the cache first before fetching the data from the server. Alternatively, on a full page reload, it will always fetch the latest data from the server and reset the TTL.

Prefetching data

With a caching layer like this in place, it is extremely straightforward to add prefetching. In the example above, all we need to do is call getInitialProps so the cache is populated with all the necessary data to load the page. Now, if this page is client-navigated to before the TTL expires, it will load instantly just like a static page! To achieve this, we can create a simple abstraction over Link, which not only downloads the page structure in the background but also calls its getInitialProps to populate the cache:

1
2import React from 'react';
3import Link from 'next/link';
4import { resolve, parse } from 'url';
5import Router from 'next/router';
6
7export default class DataPrefetchLink extends Link {
8async prefetch() {
9if (typeof window === 'undefined') {
10return;
11}
12
13const { pathname } = window.location;
14const href = resolve(pathname, this.props.href);
15const { query } = parse(this.props.href, true);
16const Component = await Router.prefetch(href);
17
18if (this.props.withData && Component) {
19const ctx = {pathname: href, query, isVirtualCall: true};
20await Component.getInitialProps(ctx);
21}
22
23}
24}

Using this in our pages is as simple as: <Link prefetch withData href="…">. In case you have some calls in your page’s getInitialProps that do not use a cache, you can use the isVirtualCall flag in the context to avoid making them when the method is called for caching purposes only. For example:

1
2import React from 'react';
3import cachedFetch, { overrideCache } from 'lib/cached-json-fetch';
4import fetch from 'isomorphic-fetch';
5
6const SOME_DATA_URL = '/some_data';
7
8export default class SomePage extends React.Component {
9static async getInitialProps(ctx) {
10const someData = await cachedFetch(SOME_DATA_URL);
11const isServerRendered = !!ctx.req;
12const isVirtualCall = ctx.isVirtualCall;
13
14// No need to call this when prefetching the page,
15// since this data won’t be cached
16let someNonCachedData;
17if (!isVirtualCall) {
18someNonCachedData = await fetch('/some_non_cached_data')
19.then(response => response.json());
20}
21
22return { someData, someNonCachedData, isServerRendered };
23
24}
25
26componentDidMount() {
27// When the page is server-rendered,
28// we override the value in the client cache
29if (this.props.isServerRendered) {
30overrideCache(SOME_DATA_URL, this.props.someData);
31}
32}
33}

The key element that makes this possible is that the programmatic prefetch API through Router.prefetch returns the page’s constructor, so we can call the getInitialProps directly on that object! You can find this extended Link component in npm as data-prefetch-link. In conclusion, we learned that with some fine-tuning we can make our dynamic next.js pages load as fast as static pages, without sacrificing the freshness of the data or the flexibility to choose which data we want to cache. We hope this helps you make your web platforms faster!

Get Started Today