A Complete Guide To Using Cookies in Next.js

A Complete Guide To Using Cookies in Next.js

Cookies are a fundamental aspect of web development. You can use them for a wide array of tasks, from user authentication and session management to tracking user preferences and storing temporary data.

As Next.js has grown in complexity, it means there are a number of different ways to use cookies, each with their own caveats. You can read them in server components in the App Router, but you can’t modify them. You can set them in getServerSideProps using the Pages Router and make them HttpOnly so they can’t be read from Javascript. You can use middleware to set them, but, currently, you can’t easily forward them from your middleware to your downstream routes.

It can be a lot, but in this guide, we’ll walk through all the different ways to use cookies in Next.js.

Cookies with the Pages Router

In general, cookies within the Pages Router are pretty straightforward.

For this section, we’re going to just be talking about reading/writing cookies on the server. For more on reading/writing cookies in the browser, see the Client-Side Cookies section further down.

Cookies in getServerSideProps

getServerSideProps is a function that you can export from your pages that will be called server-side before the page renders. Because it runs on the server, you can use it to get or set HttpOnly cookies.

Let’s look at a really simple example, like displaying a different message the first time a user visits a page vs subsequent times. If the cookie isn’t present, we’ll assume this is their first time visiting the site and we’ll set it for future requests. If it is present, we know they’ve been here before.

We’ll be using this example for the rest of the post, and while it’s not necessarily the best use case for cookies, its easy to understand and can be extrapolated to better uses for cookies.

import {GetServerSideProps} from "next";

export type WelcomeMessageProps = {
    message: string
}
export default function WelcomeMessage({message}: WelcomeMessageProps) {
    return <h2>{message}</h2>
}

export const getServerSideProps: GetServerSideProps = async (context) => {
    // If we've seen this person before, display a "Welcome back!" message
    const viewedWelcomeMessage = context.req.cookies.viewedWelcomeMessage
    if (viewedWelcomeMessage === "true") {
        return { props: {message: "Welcome back!"} }
    }

    // Otherwise, display a "Welcome!" message, but set a cookie so we don't show it again
    context.res.setHeader('Set-Cookie', 'viewedWelcomeMessage=true') // TODO: better cookie string
    return { props: {message: "Welcome!"} }
}

The context parameter contains everything you need. To read the cookie, we used context.req.cookies, which is a string ⇒ string map of the cookie’s name to its value. To write the cookie, we used context.res.setHeader to set a Set-Cookie header with our cookie string.

The string viewedWelcomeMessage=true is a valid cookie, but there’s a lot more that we can set. For example, the string viewedWelcomeMessage=true; Max-Age=600; Secure; HttpOnly is still a cookie with the name viewedWelcomeMessage and the value true, but it also will expire after 10 minutes (Max-Age), is only accessible on HTTPS sites/localhost (Secure), and is only accessible from the server (HttpOnly).

You don’t need to construct this string manually, you can use a library like cookie to do it for you:

cookie.serialize('viewedWelcomeMessage', 'true', {
    httpOnly: true,
    maxAge: 60 * 10,
    secure: true,
})
// produces "viewedWelcomeMessage=true; Max-Age=600; HttpOnly; Secure"

Cookies in API Routes

Setting cookies within API Routes is just as simple. If we wanted to take our same welcome message and instead turn it into an API, we can do this by making an API Route in pages/api/message.ts

import type {NextApiRequest, NextApiResponse} from 'next'
import cookie from "cookie";

export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse<{ message: string }>
) {
    const viewedWelcomeMessage = req.cookies.viewedWelcomeMessage
    if (viewedWelcomeMessage === "true") {
        return res.status(200).json({message: "Welcome back!"})
    }

    res.setHeader('Set-Cookie', cookie.serialize('viewedWelcomeMessage', 'true'))
    return res.status(200).json({message: "Welcome!"})
}

The code should look basically the same. When you visit http://localhost:3000/api/message for the first time after clearing your cookies, you’ll see “Welcome!” and every subsequent time, you’ll see “Welcome back!”

I should also point out that if you are using API Routes, you should check that the HTTP method is appropriate, but I wanted to keep this focused on just the cookie code.

Another quick aside - careful how you set multiple cookies

If you have an endpoint where you want to set more than one cookie at a time, you might be tempted to do this:

// (!) This is wrong, don't do this
res.setHeader('Set-Cookie', cookie.serialize('viewedWelcomeMessage', 'true'))
res.setHeader('Set-Cookie', cookie.serialize('viewedOnboardingMessage', 'true'))

But this actually doesn’t work as the second call overwrites the full Set-Cookie header. Instead, you’ll want to do this:

res.setHeader('Set-Cookie', [
    cookie.serialize('viewedWelcomeMessage', 'true'),
    cookie.serialize('viewedOnboardingMessage', 'true'),
])

which will set both cookies in the same response.

Cookies with the App Router

The App Router introduced a cookies function that makes it easier to get and set cookies, but there are some gotchas to be aware of.

Cookies in Route Handlers

Route Handlers in the App Router are analogous to API Routes in the Pages Router. If we rewrite our example from above, but this time place it in app/api/message/route.ts

import {NextRequest, NextResponse} from "next/server";

export async function GET(request: NextRequest) {
    // Note that viewedWelcomeMessage (if it exists) is an object with 
    // a name and value field, not a string like before
    const viewedWelcomeMessage = request.cookies.get("viewedWelcomeMessage")
    if (viewedWelcomeMessage?.value === "true") {
        return NextResponse.json({message: "Welcome back!"})
    }

    const response = NextResponse.json({message: "Welcome!"})
    response.cookies.set("viewedWelcomeMessage", "true")
    return response
}

And this is basically the same thing we had before. Note that we are specifically taking in NextRequest instead of Request as it adds the cookies field.

If we did want to specify additional cookie options, we no longer need to use an extra library, the set call takes them in as options:

response.cookies.set("viewedWelcomeMessage", "true", {
    httpOnly: true,
    maxAge: 60 * 10,
    secure: true,
})

And while all of this works, we can also use the cookies function from next/headers:

import {NextResponse} from "next/server";
import {cookies} from "next/headers";

export async function GET() {
    const viewedWelcomeMessage = cookies().get("viewedWelcomeMessage")
    if (viewedWelcomeMessage?.value === "true") {
        return NextResponse.json({message: "Welcome back!"})
    }

    cookies().set("viewedWelcomeMessage", "true")
    return NextResponse.json({message: "Welcome!"})
}

These two are basically identical (in the past there was a subtle difference around dynamic routing as highlighted by this Github issue, but I believe that issue was fixed), so you can choose whichever style you prefer.

Cookies in Server Components

React Server Components (RSC) let you write React components that are rendered on the server. Let’s try rewriting the same welcome message logic in a server component app/components/message.tsx

import {cookies} from "next/headers";

export default function Message() {
    const viewedWelcomeMessage = cookies().get("viewedWelcomeMessage")
    if (viewedWelcomeMessage?.value === "true") {
        return <div>Welcome back!</div>
    }

    // (!) this is wrong don't do this
    cookies().set("viewedWelcomeMessage", "true")
    return <div>Welcome!</div>
}

And we’ll set it up in a page, app/message/page.tsx

import Message from "@/app/components/message";

export default function App() {
    return <Message />
}

This looks basically the same as everything else we’ve done, but when we go to http://localhost:3000/message we are greeted with this error:

Error: Cookies can only be modified in a Server Action or Route Handler. Read more: https://nextjs.org/docs/app/api-reference/functions/cookies#cookiessetname-value-options

Ultimately, what this means is that we cannot support this behavior with just Server Components. We need to use either an API call that sets the cookie, set the cookie in JS in the browser, use Server Actions, or we can use middleware, which we’ll cover next.

Cookies in Middleware

Next.js also supports middleware, which is similar to middleware in basically every other framework. You can see and modify the incoming requests and you can also see and modify the outgoing responses.

Let’s revisit our last example:

import {cookies} from "next/headers";

export default function Message() {
    const viewedWelcomeMessage = cookies().get("viewedWelcomeMessage")
    if (viewedWelcomeMessage) {
        return <div>Welcome back!</div>
    }
    // Note that since this is a server component, 
    //   we cannot set the cookie here
    return <div>Welcome!</div>
}

And let’s get it to work now using middleware. We’ll set up our middleware to just handle the single route /message :

import {NextResponse} from 'next/server'

export function middleware() {
    const response = NextResponse.next()
    response.cookies.set("viewedWelcomeMessage", "true");
    return response
}

export const config = {
    matcher: '/message',
}

And now it works like all the examples before. The full flow of what’s going on is:

  • The middleware runs and calls NextResponse.next() indicating that Next should continue with the request.
  • The Message component renders, checking if the viewedWelcomeMessage cookie had been set and returning the correct message.
  • The middleware continues running and sets a cookie on the response before it is returned to the user.

You can take this further by conditionally setting the cookie based on the response. In a more complicated example, you might want to check response.status to make sure you only set the cookie on successful responses.

This approach is useful either for augmenting server components, or sharing your cookie logic across multiple routes/pages at the same time.

Forwarding Modified Cookies in Middleware

We’ve seen that setting a cookie on the response is pretty straightforward in middleware, but what if we want to modify the cookie before the underlying page/route loads?

This pattern is very common in use cases like authentication, where you want to refresh a token and have all the downstream server components and routes see the updated token.

If we clear our cookies, and change our middleware to this:

import {NextRequest, NextResponse} from 'next/server'

export function middleware(request: NextRequest) {
    request.cookies.set("viewedWelcomeMessage", "true");
    return NextResponse.next()
}

export const config = {
    matcher: '/message',
}

what would you expect it to display?

If you said “It should always display ‘Welcome back!’ because the request always has the viewedWelcomeMessage cookie set” - that’s an incredibly reasonable take, but it’s unfortunately wrong. Modifying the request in middleware doesn’t impact the request anywhere else.

What we have to do is… a little involved. Right now, Next.js specifically allows you to pass down new headers, like so:

export function middleware(request: NextRequest) {
  // Clone the request headers and set a new header `x-hello-from-middleware1`
  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-hello-from-middleware1', 'hello')
  const response = NextResponse.next({
    request: {
      // New request headers
      headers: requestHeaders,
    },
  })
  // ...
}

So one approach we can take is to grab and modify the cookie header:

export function middleware(request: NextRequest) {
    const newRequestHeaders = withRewritenCookieHeader(request.headers)
    const response = NextResponse.next({
        request: {
            headers: newRequestHeaders
        }
    })
    // Don't forget to set the cookie on the response as well
    response.cookies.set("viewedWelcomeMessage", "true")
    return response
}

const withRewritenCookieHeader = (requestHeaders: Headers): Headers => {
    // Get the cookies from the request headers
    const cookies = requestHeaders.get("cookie")

    // Parse them into a key-value object and update our specific cookie
    const parsedCookies = cookie.parse(cookies || "")
    parsedCookies["viewedWelcomeMessage"] = "true"

    // Serialize the cookies back into a string and update the request headers
    const serializedCookies = []
    for (const [key, value] of Object.entries(parsedCookies)) {
        serializedCookies.push(cookie.serialize(key, value))
    }

    const newRequestHeaders = new Headers(requestHeaders)
    newRequestHeaders.set("cookie", serializedCookies.join("; "))
    return newRequestHeaders
}

And now, the user will always see the “Welcome back!” message since the middleware is always passing along a request with a viewedWelcomeMessage cookie set.

If you are thinking that you don’t really love the idea of parsing and re-serializing all the cookies, just so the middleware can update the request, for one, I agree. For two, there is a slightly simpler approach - just make your own header.

In your route/component, you can do something like

const viewedWelcomeMessage = cookies().has("viewedWelcomeMessage") 
                          || headers().has("x-viewed-welcome-message");

In your middleware, you can then just set that specific header:

export function middleware(request: NextRequest) {
    // Don't allow the user to set the header themselves,
    //  this can only be set by our middleware
    if (request.headers.has("x-viewed-welcome-message")) {
        throw new Error("Cannot set x-viewed-welcome-message header")
    }

    const newRequestHeaders = new Headers(request.headers)
    newRequestHeaders.set("x-viewed-welcome-message", "true")

    const response = NextResponse.next({
        request: {
            headers: newRequestHeaders
        }

    })

    // Don't forget to set the cookie on the response as well
    response.cookies.set("viewedWelcomeMessage", "true")
    return response
}

And while all of this is… fine, it’s not necessarily ideal. Hopefully this is something that gets better support within Next.js in the future.

Cookies in Server Actions

Server Actions are now stable in Next 14. They allow you to do things like, say, write SQL queries directly in your components.

Most commonly, you’ll see them triggered by an action, like submitting a form. Our same example, using Server Actions, looks like this:

import {cookies} from "next/headers";

export default function Message() {
    // A server action that just sets the cookie
    async function markAsSeen() {
        'use server'
        cookies().set("viewedWelcomeMessage", "true");
    }

    // Our component which displays a message if the cookie is set
    const viewedWelcomeMessage = cookies().has("viewedWelcomeMessage")
    if (viewedWelcomeMessage) {
        return <div>Welcome back!</div>
    }

    // Obviously, this pattern is a little weird, but it's just to demonstrate the idea
    return <form action={markAsSeen}>
        Welcome!
        <button type="submit">Mark as seen</button>
    </form>
}

This provides another option for us to use to manage cookies, as Server Actions will run on the server but can be called from either client or server components.

Deleting cookies

I’d be hesitant to call this a complete guide without mentioning that cookies can be deleted as well. For functions like cookies(), there is a delete function that will do all the work for you. If you are manually specifying the cookie, you can delete by setting an empty value along with an expiration in the past.

Client-Side Cookies

Up until now, we’ve exclusively talked about setting/reading cookies on the server. We’ve even seen that you can make a cookie HttpOnly - meaning your browser will enforce that it is inaccessible to javascript (e.g. document.cookie or libraries like react-cookie). This is beneficial for any sensitive cookies, as it can mitigate the effect of an XSS attack.

In practice, if you are making a non-HttpOnly cookie, it’s worth asking if there’s a better way to do things. For example, if you want to store some information in the browser, another option is localStorage. localStorage can store more data and isn’t sent along on every request.

If you do want to use client-side cookies, libraries like react-cookie or universal-cookie can provide a way nicer experience. It means you can write familiar code like:

const [cookies, setCookie] = useCookies(['name']);