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
:
import lscache from 'lscache'; import fetch from 'isomorphic-fetch'; const TTL_MINUTES = 5; export default async function(url, options) { // We don't cache anything when server-side rendering. // That way if users refresh the page they always get fresh data. if (typeof window === 'undefined') { return fetch(url, options).then(response => response.json()); } let cachedResponse = lscache.get(url); // If there is no cached response, // do the actual call and store the response if (cachedResponse === null) { cachedResponse = await fetch(url, options) .then(response => response.json()); lscache.set(url, cachedResponse, TTL_MINUTES); } return cachedResponse; } export function overrideCache(key, val) { lscache.set(key, val, TTL_MINUTES); }
We then use this in the getInitialProps
of our pages:
import React from 'react'; import cachedFetch, { overrideCache } from 'lib/cached-json-fetch'; const SOME_DATA_URL = '/some_data'; export default class SomePage extends React.Component { static async getInitialProps(ctx) { const someData = await cachedFetch(SOME_DATA_URL); const isServerRendered = !!ctx.req; return { someData, isServerRendered }; } componentDidMount() { // When the page is server-rendered, // we override the value in the client cache if (this.props.isServerRendered) { overrideCache(SOME_DATA_URL, this.props.someData); } } }
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:
import React from 'react'; import Link from 'next/link'; import { resolve, parse } from 'url'; import Router from 'next/router'; export default class DataPrefetchLink extends Link { async prefetch() { if (typeof window === 'undefined') { return; } const { pathname } = window.location; const href = resolve(pathname, this.props.href); const { query } = parse(this.props.href, true); const Component = await Router.prefetch(href); if (this.props.withData && Component) { const ctx = {pathname: href, query, isVirtualCall: true}; await Component.getInitialProps(ctx); } } }
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:
import React from 'react'; import cachedFetch, { overrideCache } from 'lib/cached-json-fetch'; import fetch from 'isomorphic-fetch'; const SOME_DATA_URL = '/some_data'; export default class SomePage extends React.Component { static async getInitialProps(ctx) { const someData = await cachedFetch(SOME_DATA_URL); const isServerRendered = !!ctx.req; const isVirtualCall = ctx.isVirtualCall; // No need to call this when prefetching the page, // since this data won’t be cached let someNonCachedData; if (!isVirtualCall) { someNonCachedData = await fetch('/some_non_cached_data') .then(response => response.json()); } return { someData, someNonCachedData, isServerRendered }; } componentDidMount() { // When the page is server-rendered, // we override the value in the client cache if (this.props.isServerRendered) { overrideCache(SOME_DATA_URL, this.props.someData); } } }
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!