Reusing responses at build time

In some applications, like an e-commerce we have to query our database for list and fetch operations that returns the same data schema. But given that we usually have to call this methods inside getStaticProps and getStaticPaths we end up querying our services way more times than needed.

Imagine the following scenario. We have an e-commerce with 100 products and two routes that we statically generate at build time, an / route that list all the products and a /[id] route that shows the product details. We asume that we have an endpoint that returns all the products with the relevant data we need to pre-render our page. Every roundtrip to our service takes 150ms and consumes 1kb of bandwidth per product.

The graph below ilustrates how getStaticProps makes a call to the DB for the listing page and for each product:

A graph showing how fetching products without caching looks like
  • getStaticPaths: 100kb bandwidth, 150ms execution time, 1 call.
  • getStaticProps: 100kb bandwidth, 15000ms execution time, 100 calls.
  • Total: 200kb bandwidth, 15150ms execution time, 101 calls.

But what if we could reuse the response from getStaticPaths in our getStaticProps calls?

A graph showing how caching product details makes a big difference
  • getStaticPaths: 100kb bandwidth, 150ms execution time, 1 call.
  • getStaticProps: 0kb bandwidth, ~ 0ms execution time. 0 calls.
  • Total: 100kb bandwidth, 150ms execution time, 1 call.

And what if we can reuse that cache at application level?

A graph showing how to cache the products across pages

A real-world example

Lets start with some unoptimized code for our /[id] page.

export const getStaticPaths = async () => {
  const products = await api.list()

  return {
    paths: products.map(product => ({
      params: {
        id: product.id
      },
    })),
    fallback: 'blocking'
  }
}

export const getStaticProps = async ({params}) => {
  const product = await api.fetch(params.id)

  if (!product) {
    return {
      notFound: true
    }
  }

  return {
    props: {
      product
    }
  }
}

Lets add a cache using fs to share state at build time between getStaticPaths and getStaticProps. We will add a cache property to api with a get and a set method to interact with the cache.

const api = {
  list: async () => {
    return PRODUCTS
  },
  fetch: async (id: Product['id']) => {
    return PRODUCTS.find((product) => product.id === id)
  },
  cache: {
    get: async (id: string): Promise<Product | null | undefined> => {
      const data = await fs.readFile(path.join(process.cwd(), 'products.db'))
      const products: Product[] = JSON.parse(data as unknown as string)

      return products.find((product) => product.id === id)
    },
    set: async (products: Product[]) => {
      return await fs.writeFile(
        path.join(process.cwd(), 'products.db'),
        JSON.stringify(products)
      )
    },
  },
}

And we will use this methods in getStaticPaths and getStaticProps:

export const getStaticPaths = async () => {
  const products = await api.list()

  await api.cache.set(products)

  return {
    paths: products.map(product => ({
      params: {
        id: product.id
      },
    })),
    fallback: 'blocking'
  }
}

export const getStaticProps = async ({params}) => {
  let product = await cache.get(params.id);

  if (!product) {
    product = await api.fetch(params.id)
  }

  if (!product) {
    return {
      notFound: true
    }
  }

  return {
    props: {
      product
    }
  }
}

That way we ensure to use always information cache-first and if we don't find it, we fallback to calling the API. If you want to optimize this to be cross-page you can move the cache to other file and reuse it.

But there is something else we should take care of. Our current code might collide with our revalidation process in case we do ISR, so we want to ensure to only read from cache if we are at build time.

import { PHASE_PRODUCTION_BUILD } from 'next/constants';
...

export const getStaticPaths = async () => {
  const products = await api.list()

  if (process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD) {
    await api.cache.set(products)
  }

  return {
    paths: products.map(product => ({
      params: {
        id: product.id
      },
    })),
    fallback: 'blocking'
  }
}

export const getStaticProps = async ({params}) => {
  let product = await cache.get(params.id);

  if (!product) {
    product = await api.fetch(params.id)
  }

  if (!product) {
    return {
      notFound: true
    }
  }

  return {
    props: {
      product
    }
  }
}

Now we check if NEXT_PHASE is PHASE_PRODUCTION_BUILD so we know we only write to cache at build time. If you want to always cache build-time responses instead of manually caching at page level, you can move the usage of the cache methods to the level needed for your application.


This is a list to our products, each one will redirect to the /[id] route that was generated reusing responses from the cache at build time.