Cookies are a very useful HTTP feature. In this post, I will walk you through how you can work with cookies in a Next.js project. I will also describe how you can use cookies securely since cookies are not suitable for storing sensitive information by default.
A bit about HTTP cookies
HTTP cookies are values that are sent along with every HTTP request to a specific domain. A raw cookie value looks something like this.
cookie=tasty
In this case, the cookie is named cookie
and its value is tasty
. You can also specify extra attributes to control the conditions under which this cookie should be sent.
A cookie can be set on both the server and client side. When a cookie is set on the server side, the server sends the cookie to the client through an HTTP header called Set-Cookie
. When a client sees this header, it stores the cookie and sends it along with every HTTP request that matches the attributes set on the cookie. If no attributes are specified, it is sent by default to all requests made to the domain on which it was set.
Next.js provides several utilities to make it easier to work with cookies. Let's take a look at them.
(Note: The approaches below work only with Next.js's App router. The same functionality can be achieved using the older Pages router but it requires a different approach. The code samples below use Javascript but you can easily use Next.js's built-in Typescript support by using the .ts
file extension instead of .js
)
Setting cookies
Next.js's main utility for working with cookies is the cookies
helper (import { cookies } from 'next/headers';
). This helper can be used within server actions and route handlers to set cookies. Before we jump into setting cookies, let's see how we can work with server actions.
Setting up server actions
To create a server action, you first need to create a new js file and place the "use server"
directive at the top of the file. This marks all functions within the file as server actions.
For example, you can create a file called actions.js
in the app
folder. You can use any name for the file as long as it isn't already used by another Next.js convention (e.g. page.js
, layout.js
, template.js
).
app/actions.js
'use server'
export const login = async () => {
// Logic goes here
}
The login
function shown above is now a server action and it can be invoked from both server and client components.
To invoke the action from a server component, you can use the form
component. Next.js enhances the built-in HTML form
component so that a server action can be invoked using the action
prop.
app/login/page.js
import { login } from '../actions';
const LoginPage = () => (
<form action={login}>
<input type="text" name="username" />
<input type="password" name="password" />
<button type="submit">Login</button>
</form>
)
export default LoginPage;
When the form above is submitted by clicking the Login button, the server action can get the form's data by using the formData
parameter.
app/actions.js
'use server'
export const login = async (formData) => {
// formData is an instance of the built-in FormData interface
// https://developer.mozilla.org/en-US/docs/Web/API/FormData
const username = formData.get('username');
const password = formData.get('password');
}
You can invoke a server action from a client component by calling the function. Any parameters passed to the function will be received by the server action.
app/components/QuickLogin.js
'use client'
import { useState } from 'react';
import { login } from '../actions';
const QuickLogin = () => {
const [loggedIn, setLoggedIn] = useState(false);
const handleClick = () => {
// Get username and password from somewhere
// Pass the username and password as parameters to the action
// When invoked like this, the server action will receive these parameters instead of formData
login(username, password).then(() => {
setLoggedIn(true)
});
};
return (
<div>
{loggedIn && <p>Logged in successfully</p>}
<button onClick={handleClick}>Login</button>
</div>
);
};
Setting cookies in a server action
In the case of the login
server action above, we can use the cookies
helper to store the user's data in a cookie after their credentials are verified.
'use server'
import { cookies } from 'next/headers';
export const login = async (username, password) => {
// Verify that the username and password is correct
// Set a cookie with the user's details after their credentials are verified
cookies().set(
'user', // Name of the cookie
JSON.stringify({ username }), // Value of the cookie. Since it can only be a string, JSON can be used to store multiple values
{ // Cookie attributes
httpOnly: true, // This makes the cookie unreadable by client Javascript.
secure: true, // Cookie will only be sent over HTTPS connections
maxAge: 2592000 // Cookie will be cleared after this duration (30 days in seconds)
}
);
};
Reading cookies
Once set, a cookie can be read on both the client and the server. However, if a cookie is marked as HttpOnly
, it can only be read by the server. In this post, we will only cover how cookies can be read on the server.
Reading cookies within server components
We can read the cookie from the last section like this.
app/home/page.js
import { cookies } from 'next/headers';
const user = JSON.parse(cookies().get('user')?.value || null)
const HomePage = () => (
<div>
{user && (
<p>Logged in as {user.username}</p>
)}
{!user && (
<p>Not logged in</p>
)}
</div>
)
export default HomePage;
While we could read the cookie manually on each page to check if the user is logged in, a more convenient way to run this check is to use middleware.
Reading cookies within Middleware
Middleware allows us to specify logic that should run when a request is made to a Next.js server. This logic runs before Next.js renders the page component for the specified URL.
To set up middleware, a file named middleware.js
should be placed at the project root. This file should export a function named middleware
and an object named config
. The config
object specifies the paths on which the middleware
function should run. The middleware
function is given the incoming request as a Request object and it can return a custom Response will be sent to the browser instead of the page at the URL. Let's look at a few examples.
The middleware below redirects users to the login page when they try to view the home while not logged in.
middleware.js
import { NextResponse } from 'next/server';
export function middleware(request) {
// The cookies helper can't be used in a middleware.
// But Next.js extends the built-in Request object so that cookies can be read or set in a similar way to the cookies() helper
const user = JSON.parse(request.cookies.get('user')?.value || "{}");
if(!user?.username) {
// NextResponse extends the built-in Response object so that
// redirects and rewrites can be done more easily
return NextResponse.redirect(
// The second parameter of the URL constructor is the base URL
// It is required when a relative URL such as /login is provided.
new URL('/login', request.url)
);
}
}
export const config = {
// Specify that the middleware function above should only run when /home is requested.
// Multiple paths and regexes can also be specified here
matcher: '/home'
}
The middleware below "rewrites" the /home
URL to show the content of the /not-authorized
page when the user is not logged in.
middleware.ts
import { NextResponse } from 'next/server';
export function middleware(request) {
const user = JSON.parse(request.cookies.get('user')?.value || "{}");
if(!user?.username) {
return NextResponse.rewrite(
new URL('/not-authorized', request.url)
);
}
}
export const config = {
matcher: '/home'
}
Cookies and security
In the sections above, we used the presence of a cookie to allow a user to view protected pages. But this is insecure since someone could easily set a fake cookie and gain access.
To avoid such attacks, we need to ensure that the cookie was legitimately set by the server. We can do this by signing the cookies. However, signing the cookie will not prevent a third party from reading the data that is stored within it. To prevent the data from being read, we can encrypt the cookie values or store only an id in the cookie.
Signing cookies
A cookie can be signed by including a signature in the cookie value to verify that it was set by the server.
A common way to sign values is to use JSON Web Tokens (JWT). A JWT contains a signature that verifies that the "payload" it carries was set by a trusted party. A JWT can be created and verified in many ways but in this post, we will be using the jose
npm package.
You can create a JWT token using jose
like this.
import { SignJWT }from 'jose';
// SECRET_VALUE is a value known only by the server
// Later we can verify whether the JWT token was created using this value and reject the token if it wasn't
// You should set up your own value here and store it securely
// Anyone with this value can construct tokens that will be verified as valid
const secret = new TextEncoder().encode('SECRET_VALUE');
// A JWT can contain a "payload" with custom data
// In this case, the payload is an object containing the username
const token = await new SignJWT({ username })
// A signed JWT requires a header with the algorithm (alg) used to create the signature
// HS256 is an algorithm for creating a signature using a secret key like the one specified above
.setProtectedHeader({ alg: 'HS256' })
// A JWT can contain "claims" that provide additional information about the token
// The claims set below are registered claims
// They are standardized so that many applications can understand them
// https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-token-claims#registered-claims
.setExpirationTime('30 days')
// The subject is the user for which the JWT is generated for.
// Often the userId is stored here to identify the user
.setSubject(userId)
// Store the current time as the time at which the token was issued
.setIssuedAt()
// Sign the token created above with the header and payload with claims using the secret key
.sign(secret);
A JWT token can be verified like this.
import { jwtVerify } from 'jose';
// Use the same secret used to sign the token
const secret = new TextEncoder().encode('SECRET_VALUE');
try {
const { payload } = await jwtVerify(token, secret);
console.log(`${payload.username} is logged in`);
} catch (e) { // jwtVerify will throw an error when the token is invalid
console.error('Invalid or expired token');
}
Encrypting cookies
We can encrypt the value stored within the cookie so that it can't be read by third parties. We can also use jose
to create an encrypted JWT using the EncryptJWT
class.
import { EncryptJWT } from 'jose'
// The 'A128CBC-HS256' algorithm requires a key that is 256 bits long.
// That is 32 characters
const secret = new TextEncoder().encode('A_SECRET_VALUE_THAT_IS_32_CHAR__');
const token = await new EncryptJWT({ username })
// Use Direct encryption mode by specifying alg: 'dir'. This is used when there is only a single secret.
// Use `A128CBC-HS256` algorithm for encrypting the value
.setProtectedHeader({ alg: 'dir', enc: 'A128CBC-HS256' })
// Same claims as the previous example
.setExpirationTime('30 days')
.setSubject(userId)
.setIssuedAt()
// Encrypt the token using the secret key
.encrypt(secret);
The value of an encrypted JWT can only be read after it has been decrypted. We can decrypt an encrypted JWT using jose
like so.
import { jwtDecrypt } from 'jose';
// Use the same secret value that was used to encrypt the token
const secret = new TextEncoder().encode('A_SECRET_VALUE_THAT_IS_32_CHAR__');
try {
const { payload } = await jwtDecrypt(token, secret);
console.log(`${payload.username} is logged in`);
} catch (e) { // jwtDecrypt will throw an error when the token is invalid
console.error('Invalid or expired token');
}
Using IDs
Another approach to secure values stored in cookies is to store ids within cookies that reference records stored elsewhere (usually a database). Then when a request is received with that cookie, Next.js can retrieve the record using the id to get the data that corresponds to that cookie.
This practice is often called session management. A new session record can be created when a user logs in with information about the validity of the session. With this approach, a user's access to the app can also be revoked by changing the session records. The downside of this approach is that it is more complicated to set up.
Since the implementation of this approach varies heavily based on how the data is stored, we will not cover it in this post.