Go back

Persisting auth state in Next.js, React Context and cookie wrappers

A while ago I shared how I approached implementing auth in a Next.js app with the backend APIs that were provided for me. But what I failed to mention was how everything felt wonky.

People would try logging in, go away from their browser, and come back after a while, only to find out that all the requests are failing with a 401 status code, indicating the authentication token I already stored in a cookie is invalid.

But, how can a cookie value whose expiry date is still in the future be invalid? Just how?

This issue lingered for a very long time. Safe to say since the start of the project itself, if I'm not wrong.

I've heard and seen a lot of Frontend devs express their displeasure towards authentication flows in general, I had no idea this is how it'd be like because this isn't my first rodeo with auth in Next.js.

I held my head high thinking that I'd be immune to this. LOL! I was devoid of wisdom.

Since I had already done something related to auth before and persisting auth state. I even wrote about them here and here again. I went back to these resources as usual, but the solution to this issue was not found in them.

And yes, I know that you might go on to say "What's the fuss all about?! Just use React Context API", and I'm here to tell you that you might be wrong.

Not Understanding how I was supposed to architect this project, and the tool(s) to use based on the decisions I made were also part of why this issue persisted for a very long time. So, I'd recommend that you understand your project first before setting out to build.

React Context — an illusion of state management

Now, back to why React context wasn't the de facto solution to my problem, how it isn't a state-management tool per se, or didn't fall into the category based on the needs of the project.

React Context is a way for you to reduce or eradicate the issue of "prop drilling", not to manage state. State management is done by you and your code. Just as Mark Erikson puts it here.

No. Context is a form of Dependency Injection. It is a transport mechanism - it doesn't "manage" anything. Any "state management" is done by you and your own code, typically via useState/useReducer.

But, the concept of Context and whether or not to use it for state isn't in the scope of this article. I mentioned it because It contributed to the re-rendering issue I faced, most definitely because I "used it wrongly" and it did not suit my use case.

Another thing to note. Knowing when to use a particular tool, pattern, or hook in your project is important. Instead of following what's hot or flashy, you should use what suits your project's needs.

Mark mentioned something about how Context affects rendering in React applications in this article series — Blogged Answers — where he answers questions around things some folks might've asked in the past.

This section and the one immediately below it focuses on state updates and the whole issue of Context and render optimizations.

This tweet from Sophie Alpert suggests using React.memo to optimize components dependent on a context provider.

I have a hypothesis; I think React Context works well to provide this illusion of state with sources (APIs) that are in a way coupled together.

Say for example, when you hit an endpoint that supposedly authenticates a user, the response you get from the server contains all the user info you need, such that you don't need props to pass data down the component tree.

data: {
 name: "some value",
 username: "heygo",
 isUserVerified: true,
 createdAt: "some date string",
 consumptionRate: "some value"
 location: {
  houseAddress: "14, Idi iroko way, Yaba Shomolu AJ, Lagos",
  officeAddress: "2, Point Road, Pelewura Crescent"
 },
 ...theListGoesOn
}

Then you'd go ahead and pass these data into the UI component that depends on them. I may be wrong still.

I'd like to consider React-Query and SWR, as tools that gracefully address the issue of state management on the client, if you do not want to depend on heavy frameworks like Redux and its counterparts because they are server caching tools and they readily provide a way for state updates via server mutations.

I'm familiar with how it's done when I use SWR with the mutate() function in my data hooks. Maybe someday in the future, I'll publish a piece on data/state mutations.

import useSWR, { mutate } from 'swr'
import React from 'react'
 
export const userData = () => {
  const { data, error, isLoading, isValidating } = useSWR(
    `/api/some-userdata`,
    () => aCallbackFn(),
    {
      revalidateIfStale: true,
      revalidateOnFocus: true,
    }
  )
 
  React.useEffect(() => {
    if (workspace) {
      fetchUsers()
    }
  }, [user]) // only run this when this dependency changes
 
  // refetch when there's a change
  const fetchUsers = () => {
    mutate('/api/some-userdata')
  }
 
  return {
    data,
    error,
    isLoading,
    fetchUsers,
    isValidating,
  }
}

Learning about Cookies

Inasmuch as I went back to this article over and over, I still couldn't figure out why what seemed to work in another Next.js project I worked on worked perfectly fine with React Context. It was infuriating!

What I failed to realize, after several months of reading about secure authentication flows and cookie wrappers in React's ecosystem was that, In the other project, I used localStorage to preserve the auth state.

With localStorage, you'd barely encounter any issue because, once you keep data in localStorage it stays there forever — unless you programmatically clear it

localStorage.clear()

Or manually clear it yourself via your browser devtool.


But, because localStorage isn't the safest way to handle auth flows in a production-ready app.

I had to start researching cookies wrappers, and the first one I found was a Next.js cookie wrapper — nookies — with some helper functions that I could use to set and parse cookies both on the client and server.

Everything worked fine at first. I mean... C'mon! I could see the actual cookie value I'm setting and parsing in my dev tools. So, what exactly could go wrong?

The first time this issue of auth-state was reported to me, I thought it'd definitely be because of the cookie wrapper. So, I decided to use another cookie wrapper — js-cookie.

At first, it gave me the illusion of success, until it didn't. The issue still persisted, to the extent that the only temporary solution was for us to constantly "hard reload" before the subsequent requests to our server could be successful.

I mean, it worked, temporarily at least. But, that is the worst type of experience you want to ship to people who would end up using your product.

The question I kept asking myself was "How am I supposed to tell people to 'hard refresh' when they log back into their dashboards and they can't see anything?"

It was that bad!

Next.js API routes — the culprit

As I mentioned before. It is very important that you understand how your project works so you can decide effectively, on the type of tools you choose to use. For me, it was the choice of a cookie wrapper.

Because this project relies solely on the use of Next.js API routes, when you keep user/auth tokens in a browser cookie, you need to pass the token directly to the request object via req: NextApiRequest. Why?

Well, this is because API routes run in a server-side context. Hence the reason why all the requests I was making to those routes were failing, as the token the server is expecting isn't what the client is supplying.

That tiny disparity is always present unless we "hard refresh". Dang!

I did not even understand how this worked until I found cookies-next. I've always used API routes. How I managed to forget that its execution context is server-side, is what I still cannot fathom, till now.

With cookies-next, I was able to directly pass the cookie value to the HTTP request like so.

import { someDataEndpoint } from '..'
import { getCookie } from 'cookies-next'
import { NextApiRequest, NextApiResponse } from 'next'
 
export default async function getSomeDataApiRoute(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method === 'GET') {
    const token = getCookie('auth-token', { req, res })
    const request = await fetch(someDataEndpoint, {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Token ${token}`,
      },
    })
    const response = await request.json()
    res.status(request.status).json(response)
  } else {
    res.status(200).json({ error: 'This endpoint accepts GET request alone' })
  }
}

And that's how I bypassed an issue that lingered for ages! Formerly, I had to export the token I kept as a constant via nookies like so.

import { parseCookies } from 'nookies'
 
const cookie = parseCookies()
const authToken = cookie['auth-token']

The problem with this approach is that, nookies provided a client-side method — parseCookies() — of accessing browser cookies. Therefore, usng it in that API route as is, was wrong.

Wrapping up.

It is important to always carry out a substantial amount of research before setting out to build something, so even when you run into blockers, because you will. You won't spend so much time questioning your worth, while trying to figure these things out.

You can decide to use any cookie provider that fits your needs. If your'e not using API routes, It isn't compulsory for you to choose cookies-next. Until you find what suits your use case, don't stop searching for it. Google Search, read issues on GitHub and ask your buddy ChatGPT.