A Complete Guide To Using Cookies in Next.js | PropelAuth

Announcing MCP Authentication: secure your MCP servers with PropelAuth

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) => {
    const viewedWelcomeMessage = context.req.cookies.viewedWelcomeMessage
    if (viewedWelcomeMessage === "true") {
        return { props: {message: "Welcome back!"} }
    }

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.

A quick aside on cookie strings

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) {
    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>
    }

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.

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>
    }
    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:

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.

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) {
  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-hello-from-middleware1', 'hello')
  const response = NextResponse.next({
    request: {
      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
        }
    })
    response.cookies.set("viewedWelcomeMessage", "true")
    return response
}

const withRewritenCookieHeader = (requestHeaders: Headers): Headers => {
    const cookies = requestHeaders.get("cookie")
    const parsedCookies = cookie.parse(cookies || "")
    parsedCookies["viewedWelcomeMessage"] = "true"
    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) {
    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
        }
    })
    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() {
    async function markAsSeen() {
        'use server'
        cookies().set("viewedWelcomeMessage", "true");
    }

const viewedWelcomeMessage = cookies().has("viewedWelcomeMessage")
    if (viewedWelcomeMessage) {
        return <div>Welcome back!</div>
    }

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']);