Product
Increasing the Performance of Dynamic Next.JS Websites
by Guido Maliandi on November 6th, 2017
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!