February 15, 2021

Using vercel’s swr with next.js server side rendering

I love vercel’s swr package, it solves a many problems at once, in an elegant way. The only thing that disappointed me, especially considering it’s coming straight from next.js’ creators, is the lack of integration with next.js for universal rendering. With apollo for example, you can use getDataFromTree to block the rendering on the server until all queries are fetched. This would have been nice for swr too, instead we have to content ourselves with the possibility of adding initial data into the hook. This means that we have to build the fetching logic twice - once on the server with getInitialProps or getServerProps and once on the client with useSWR. It also means that we can't abstract everything into hooks, because that’s not supported inside getInitialProps.

Fair enough, we’re getting all this shit for free anyway, so let's see what damage we can do with this. The example from the swr docs only mentions static generation, which I don’t mess with a lot, so I needed to do this with SSR.

This construct is what I came up with:

import useSWR from 'swr'

const fetcher = (...args) =>
  fetch(...args).then(res => res.json())

const Posts = props => {
  const { data, loading } =
    useSWR('/api/posts', fetcher, {initialData: props.posts})

  ...
}

Posts.getInitialProps = async () => {
  if (typeof window !== 'undefined') {
    return {}
  }

  return await fetcher('/api/posts')
}

Pretty similar to vercel’s example, except for a few important differences:

  1. It’s necessary to use getInitialProps here, even though discouraged by vercel. If we use getServerProps, we need to wait for a round trip time to the server, needlessly.
  2. If we are running getInitialProps upon client side navigation, we don’t want to do anything, since useSWR will do the fetching for us in this case
  3. This is a reduced example, but often your fetching logic is more complex – e.g. fetching different things based on query parameters, pagination, etc. In this case it’s helpful to extract this logic into helper functions that you can reuse on the server and in the client. Hooks are off the table unfortunately, because this needs to work in getInitialProps

This configuration enables a great experience for users: you get full server side rendering, the possibility of displaying a loader while data is being fetched on the client, and a super snappy experience because client side navigation is now instant.

Authentication and pagination

This is a very basic example though, let’s see how a more complex version would look, including API authentication and pagination with endless scrolling. First we need to extend our fetcher to send the token in a header (this is a common authentication method, but you can also use any other method here):

const fetcher = async (path, token) => {
  const res = await fetch(path, {
    method: 'GET',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
    },
  })
  return await res.json()
}

Now we want a hook to hide the complexity in, so we only have to use a nice one-line hook to fetch our data in our app. Since we are using pagination, we want to be using useSWRInfinite here, which has a slightly different API: it returns an array of API responses, and its first argument is a function that returns the API path, receiving the page index as an argument.

// initialData here is the first page that you have
// fetched from getInitialProps
const usePosts = (initialData, token) => {
  const { data, size, setSize, error } = useSWRInfinite(
    index => [`/api/posts?page=${index}`, token],
    fetcher,
    {
      initialData: [initialData],
    }
  )
  return {
    data,
    size,
    setSize,
    error,
  }
}

Note that we are returning [path, token] in the function. This is what will be passed to our fetcher as arguments, and is used by swr as a cache key. It’s important to pass the token this way, so that the cache is invalidated for different tokens. Plainly said, if you log out and the token is empty, you don’t want to return the value from the cache that you got with the token – you'd want an empty response or an error.

You can now use setSize to increase the page size, for example when a user has scrolled to the button, you might want to increase the page size by one, then swr will automatically append the next page from the API to the data array.

In this example, our getInitialProps would look something like this:

Posts.getInitialProps = async ({ req }) => {
  if (typeof window !== 'undefined') {
    return {}
  }

  const { token } = parseCookies(req.headers.cookie)
  const posts = await fetcher('/api/posts?page=0', token)
  return {
    posts,
    token,
  }
}

For this approach to work with full SSR, you need to be storing the token in a cookie, so we can read it on the server. Admittedly, server side rendering is not a huge requirement for authenticated pages, because search engines won’t be able to read these pages anyway, but it’s still nice to return a hydrated page on the first request.

The last missing piece is how to use this whole thing in the render function:

const Posts = props => {
  const { data, size, setSize } = usePosts(props.posts, props.token)
  ...
}

Your data will now contain server-fetched data on the first render, and client-fetched data on client rendered pages, without any communication with the next.js server in between.