Summer 2022

Next.js App Router: Handling Cookies

When working on the rebuild of the dashboard at Covie, I opted to use the app router that was introduced in v13.

To make full use of the SSR / CSR features that come with Next.js, I was making API requests from middleware, server and client.

Due to how cookies are passed between server and client and the current state of Next.js various APIs surrounding this, this ended up being a little more complicated than first anticipated.

Authentication

Previously when implementing authentication into Next.js projects, I would have a auth hook, which in its simplest form, looked something like this:

export const useAuth = () => {
	/**
	 * Fetch User
	 *
	 * SWR's default behaviour will refresh the user when switching tabs / window focus
	 * and at a certain interval if the component consuming this is on screen
	 */
	const { data: user, error: userError } = useSWR(`/api/user`, (url) =>
		api
			.get(url)
			.then((res) => res.data)
			.catch((e) => {
				// You can catch specific errors here such as email verification with e.response.status
				// This is to prevent throwing an error when we don't actually want to logout
				throw error
			})
	)
 
	const isLoggedOut = !!userError
	return {
		user,
		isLoggedOut,
	}
}

We can then use this hook and reference isLoggedOut on individual pages / components to only show content to logged in users.

Alternatively, you can bake the redirect logic into the hook itself with a simple flag:

export const useAuth = ({ middleware } = {}) => {
  ...
 
  // Redirect to login if logged out
  useEffect(() => {
    if(middleware.authenticated === true && userError) {
      router.replace('/login')
    }
  }, [middleware, userError])
 
  ...
}

You'd then call the hook with:

const { user } = useAuth({ middleware: { authenticated: true } })

This approach works fine when you're app is largely client side rendered. The main downfall is that you may encounter a flash of UI before logging out or loading spinners while you're validating the user on the initial render. Moving between pages is largely fine, as you'll be making use of caching via <Link /> or SWR itself.

Combining SSR and CSR

When rebuilding the dashboard, I wanted to make use of SSR. There was a range of global information I was fetching on every page, such as the user, selected account and selected application.

If I could fetch and cache this information on the initial render before the client rendered anything, it would reduce the amount of skeleton UI and spinners being shown.

I also wanted to improve on how we were refreshing the set of tiered accessing tokens, as some needed refreshing every 30 minutes.

The overall fetching structure would look like:

  • Use middleware to enforce authentication and token renewal
  • Use SSR to load initial page information
  • Use CSR on dynamic pages (filters, mutations, etc)

Cookies

As we'd be using a blend of server side and client side rendering, we'd need to some way of persisting information, such as access tokens, to make it accessible in both environments.

Local storage is stored within the browser itself, so the server wouldn't have access to this information. Cookies were the next logical step, as these could be transferred via headers.

The main issue with this is that in the Next.js app directory, cookies can only be written in actions, route handlers or client components (via an external package such as js-cookie or in my case next client cookies).

Middleware

Using middleware, you can read and write cookies via the response:

export async function middleware(req: NextRequest) {
  const res = NextResponse.next()
 
  res.cookies.get("access_token")
  res.cookies.set("access_token", "123")
  ...
}

While it seems you can set cookies in middleware, there's an issue where the values are not available on the first render pass within a server component. This is a problem... if we've had to refresh an access token in middleware, it won't be available to pages where we need to fetch information via SSR - resulting in a failed request.

There's currently a work around where you essentially overwrite the headers so that the cookies are set on the initial request and not just the response. This gives you access to cookies you have modified in middleware on the first render within SSR components.

import { NextResponse, type NextRequest } from "next/server"
import {
	ResponseCookies,
	RequestCookies,
} from "next/dist/server/web/spec-extension/cookies"
 
/**
 * Copy cookies from the Set-Cookie header of the response to the Cookie header of the request,
 * so that it will appear to SSR/RSC as if the user already has the new cookies.
 */
function applySetCookie(req: NextRequest, res: NextResponse): void {
  // parse the outgoing Set-Cookie header
  const setCookies = new ResponseCookies(res.headers)
 
  // Build a new Cookie header for the request by adding the setCookies
  const newReqHeaders = new Headers(req.headers)
  const newReqCookies = new RequestCookies(newReqHeaders)
  setCookies.getAll().forEach((cookie) => newReqCookies.set(cookie))
 
  // set “request header overrides” on the outgoing response
  NextResponse.next({
    request: { headers: newReqHeaders },
  }).headers.forEach((value, key) => {
    if (
      key === "x-middleware-override-headers" ||
      key.startsWith("x-middleware-request-")
    ) {
      res.headers.set(key, value)
    }
  })
}
 
// Middleware handler
export async function middleware(req: NextRequest) {
 
  // Set cookies on your response
  const res = NextResponse.next()
  res.cookies.set("foo", "bar")
 
  // Apply those cookies to the request
  applySetCookie(req, res)
  ...
 
}

Server side

You can read cookies in SSR components as follows:

import { cookies } from "next/headers"
 
export default function Page() {
  const cookieStore = cookies()
  const theme = cookieStore.get("theme")
  ...
}

To date, you still cannot write cookies in server components. This poses a bit of a problem when dealing with access tokens and the need to refresh them; fairly regularly, in our case.

As I was refreshing tokens in middleware, and middleware will always run before an SSR component, it means that in theory I shouldn't ever have to refresh a token within an SSR component (unless the token has been revoked). This meant I could still safely make requests server side, such as loading initial global and page data.

Client side

Client side is a little easier, as we're interacting with the browser. First, cookies sent with the server response need to be synced client side. I used next client cookies for this.

<ClientCookiesProvider value={cookies().getAll()}>...</ClientCookiesProvider>

You can then read / write cookies directly with:

"use client"
 
import { useCookies } from "next-client-cookies"
 
export default function Page() {
  const cookies = useCookies()
  cookies.get("access_token")
  cookies.set("access_token", "123")
  ...
}

Abstracting

When building an app, I tend to wrap request handling in my own API handler. This will handle things such as:

  • Appending relevant headers (such as auth)
  • Handling token refresh attempts
  • CRUD functions (get, post, delete, put, file uploads, etc)
  • Contextual functions (getUser, getAccount, getApplication, etc)
export function createApiClient(): ApiClient {
  let api: ApiClient = {} as ApiClient
 
  // Base request handler
  async function request(
    endpoint: string,
    options: FetchOptions,
  ): Promise<FetchResponse> {
      ...
  }
 
  // Perform GET request
  api.get = async (endpoint: string, options: FetchOptions = {}) =>
    request(endpoint, { ...options, method: 'GET' })
 
  // Get account by ID
  api.getAccount = async (id: string) =>
    api.get(`/v1/accounts/${id}`, {
      scope: 'user',
    })
 
  ...
 
  return api
}
 

You'll notice previously that in each instance of middleware, server and client, we're accessing cookies via different methods. In order to use my own API layer across all environments, I needed to add another layer on top of the request abstraction to handle this.

This results in various initiators:

// Middleware
const api = createMiddlewareApiClient(req, res)
 
// Server
const api = createServerApiClient()
 
// Client
const api = createBrowserApiClient()

Within these instances, I can then wrap the cookie logic and pass reference functions into the API layer.

Let's take the middleware client as an example:

import { NextRequest, NextResponse } from "next/server"
import { createApiClient } from "./api"
 
export function createMiddlewareApiClient(req: NextRequest, res: NextResponse) {
	/**
	 * Retrieves a cookie
	 *
	 * @param name Cookie name
	 * @returns
	 */
	function getCookie(name: string): string {
		return res.cookies.get(name)?.value || req.cookies.get(name)?.value || ""
	}
 
	/**
	 * Sets a cookie
	 *
	 * @param name Cookie name
	 * @param value Cookie value
	 * @param expires Cookie expiry in seconds
	 */
	function setCookie(name: string, value: string, expires: number) {
		res.cookies.set(name, value, {
			maxAge: expires,
		})
	}
 
	// Create and return api client
	return createApiClient(getCookie, setCookie)
}

We now have a single local API layer to maintain with different entry points depending on the environment.

Of course you'd want to abstract this further as functionality grows, but this post was mainly to illustrate the ways in which Next.js currently interfaces cookies in the app directory and how I approached it.


I'm sure at some point the team at Next.js will engineer a solution resulting in a single cookies function which detects the environment it's being accessed from and handles the access accordingly. There's no doubt this is causing a bit of headache with the nature in which cookies are stored and transferred between environments.