Loading...
Session management is one of the most critical components of modern web applications. It is closely related to cookie management, a topic discussed in the previous article, as it is nearly impossible to manage stateless sessions without using cookies. Ensuring secure session management is not feasible without understanding the structure and security risks of cookies, which is why I suggest taking a look at the Next.js Cookie Management article for more insight.
Session management, including authentication, session initiation, and authorization controls, often becomes a complex process when trying to balance user experience with security and performance. Although third-party services provide developers with many tools and approaches, it's essential to understand the fundamentals in order to create solutions tailored to specific scenarios. Even when using third-party services for authentication or session initiation, challenges like securing private endpoints, controlling access based on authorization, and verifying sessions on the middleware or server side still need to be addressed. Ultimately, no matter how much you render content or restrict access based on session conditions on the client side, these processes cannot be securely managed without a serverless architecture or a fully functional HTTP server running in the background. Thanks to the serverless architecture provided by Next.js and the recent updates in React, managing and optimizing these processes—both on the client side with forms and on the server side with built-in methods—has become much easier, all without the need for a separate backend application.
In this article, we will first take a look at JWT and Route Handlers, highlighting the important points to consider. We will explore how to manage authentication and authorization processes in a stateless-session structure in both a secure and performance-oriented way. By comparing Server Actions and Route Handlers, the aim is to determine which approach is more suitable for different situations. Potential issues and key considerations will also be addressed to provide comprehensive solutions. Ultimately, the goal is to create a beginner friendly resource that is both helpful and free from misleading information.
Enjoy the read!..
Route Handlers
One of Next.js's powerful features that transforms React into a full-stack framework is the ease of creating API endpoints. Route Handlers, introduced with Next.js 13 as part of the App Routing structure, are functions designed for server-side operations with Edge support. These functions handle CRUD (Create, Read, Update, Delete) operations using HTTP methods like GET, POST, and PUT from clients or third-party services. Next.js supports the creation of API endpoints for REST or GraphQL, though by default, Route Handlers are stateless (in this article, the REST API structure will be used).
Route Handlers are public by nature, meaning anyone can access the URL endpoint. This makes them vulnerable to malicious requests or DDoS attacks, so security measures like JWT validation, middleware-based access control, sanitization, or rate-limiting are required. Additionally, Route Handlers support cache management, allowing requests to the endpoint to be served from the cache.
Before Next.js v13, API Routes were used to create API endpoints. Both API Routes and Route Handlers (in the App Routing structure) are serverless, responding to requests by creating a short-lived Node.js server instance. However, Route Handlers are closer to a continuously running server, supporting features like caching and revalidation, resulting in better performance. Moreover, Route Handlers can define each HTTP method as a separate function, apply unique security checks, and automatically return a 404 Not Found for undefined methods.
Feature | API Routes | Route Handlers |
---|---|---|
Supports CRUD operations | ✔️ | ✔️ |
Middleware support | ✔️ | ✔️ |
Dynamic routing | ✔️ | ✔️ |
File upload | ✔️ | ✔️ |
Separate functions for each HTTP method | ❌ | ✔️ |
Cache and Revalidate support | ❌ | ✔️ |
Stateful operations | ❌ | ✔️ |
Data streaming | ❌ | ✔️ |
Edge support | ❌ | ✔️ |
To create API endpoints, Route Handlers use a file-based routing system in the 'app' directory. Each API endpoint is structured as files that define HTTP methods for a specific route.
For example, to create the /api/auth endpoint, the file structure under src/app should look like this:
Terminal
src/
└── app/
└── api/
└── auth/
└── route.ts
Tips & Tricks
- Route Handlers handle incoming requests through the NextRequest class, and manage responses using the NextResponse class, ensuring a structured and efficient flow for both request processing and response generation.
- NextRequest, is extend from the Web Request API and includes all the common Request methods like json(), text(), headers, url, and method. Additionally, it contains Next.js-specific features such as: cookies, nextUrl, ip and ua, geo.
- Similarly, NextResponse is extend from the Web Response API, and its methods like json(), redirect(), rewrite(), next(), and error() are static, meaning they can be called directly from the class.
- The standard Response interface can be used instead of NextResponse. This can be a lighter solution, especially if the extra features provided by Next.js are not needed for specific use cases.
- For more details on status codes, you can refer to the MDN Web Docs.
Json Web Token
Before diving into an example of JWT usage, it’s worth briefly explaining what it is and what it isn't. If you're already familiar, feel free to skip ahead to the example.
JWT (JSON Web Token), is a token format used for secure state management between two parties or for the secure transfer of non-confidential information. The biggest advantage of JWT is that there's no need to store the token on the server; all the verification data is contained within the token itself. This makes processes like session authentication and access control faster and more scalable. However, to ensure security, it's crucial that the token is signed and has an expiration time.
When using JWT for session management, if the token is stored as a cookie in the user's browser, the browser automatically attaches this token to the header with every page visit or HTTP request to the server. On the server side, the token is validated through middleware, and the user's permissions are checked. This way, as long as the user's session remains active, they can securely perform actions, and different user profiles can be properly distinguished.
A JWT token consists of three parts:
- Header: This section contains metadata specifying which algorithm was used to sign the JWT and the type of token.
- Payload: This is where information (claims) such as the user’s identity and permissions are stored. These claims are encoded in base64.
- Signature: The signature is generated using a secret key and is used to ensure the confidentiality and integrity of the Header and Payload.
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secretKey )
The HMACSHA256 hash function generates an encrypted value (hash) by signing the combined data of the base64UrlEncoded header, payload, and a dot with the given secretKey. As long as the header, payload, and secretKey remain the same, the output will be identical—even if it's encrypted on the other side of the world. In short, cryptographic encryption does not produce random values.
Tips & Tricks
- A JWT token can be stored in session-storage instead of cookies, but in this case, it won’t be automatically added to the header for each request. You'll need to manually include it in requests made via fetch or axios (e.g., `'Authorization': Bearer ${token}`).
- Sensitive or personal information should not be stored in a JWT, as the content of a JWT's payload can easily be viewed using tools like jwt.io.
- The secret key used in the signature should be securely stored, ideally in environment variables (e.g., .env file). However, avoid using the NEXT_PUBLIC_ prefix, as variables with this prefix in Next.js will be exposed to the client side.
- No system is completely unbreakable; using standard claims like exp (expiration), aud (audience), iss (issuer), or jti (JWT ID) helps enhance security. These claims limit the token’s validity, specify the intended audience, identify the issuing source, and prevent token reuse.
- Decoding a JWT on the server side does not provide any security or validation. Instead, it must be verified using the verification functions provided by the library, along with additional claims. Care should be taken to ensure the correct algorithm is used.
- For more information on JWT security, see.
A useful open-source book on Cryptography:
https://cryptobook.nakov.com/
https://cryptobook.nakov.com/
Session Manangement
Session management is divided into two categories: Stateful and Stateless. Stateful session management is typically preferred for short-term, specialized sessions. For example, in processes such as an initiated payment transaction or a product transfer, session information is stored on a central server and managed by retrieving this information from the server at each step of the process. This method requires keeping session data on the server, which adds extra load to the server. However, it provides a secure and auditable solution for ensuring the integrity of certain processes. Stateful session management itself has different approaches, primarily divided into in-proc (server memory) and database storage.
The stateless-session approach, which will be the focus of this article, is based on the principle that each HTTP request is independent of the other, and session information is not stored on the server or in a database, but rather on the client-side in a cookie. This cookie is validated before each HTTP requests or when necessary, making it a method widely preferred in modern applications today. Storing session information in a cookie signed with a JWT reduces the load on the server significantly. This approach, provides significant advantages in terms of performance and scalability, especially in applications with high user traffic. However, it also brings potential security vulnerabilities such asor issues arising from improper management of JWTs, including long-lived tokens and token bloat. The root of these vulnerabilities lies in the nature of cookies where session information is stored, a topic that was discussed in the previous article. This time, we will explore how to manage session information on the server side in a secure manner, while keeping user input validation and the process hidden from the client side, and how to manage secure sessions with as little user data as possible.
When implementing this structure, first we will gather and manage user login information, establishing an effective form state management without the need for external libraries like react-form. We will then compare API endpoints (Route Handlers) with Server Actions for creating session cookies and authentication, aiming to determine the ideal session management method in terms of both security and performance. After user authentication, JWT tokens will be used to store session information. Finally, we will evaluate the Opportunistic and Strict approaches to authorization, analyzing the differences between these two methods.
In the example structure that we will implement using Next.js 15 and React 19, if the user does not possess a valid JWT token cookie, they will be redirected to the login page. No endpoints outside of the login page will be accessible to requests that fail to pass the auth check.
Dependencies & Installation
System requirements:
- Node.js 18 or higher.
Terminal
npx create-next-app@latest
The preferred options during the setup are as follows.
Terminal
What is your project named? cookies-samples
Would you like to use TypeScript? - Yes
Would you like to use ESLint? - Yes
Would you like to use Tailwind CSS? - Yes
Would you like your code inside a `src/` directory? - Yes
Would you like to use App Router? (recommended) - Yes
Would you like to use Turbopack for `next dev`? - No
Dependencies.
Terminal
# JWT verify and generation
npm i jose
# For JWE
npm i bcrypt
# Schema and type verification
npm i zod
The file structure will roughly look like this. Aside from the dashboard and login pages we've set up in the app folder, we'll redirect the user directly to the login page without using a separate homepage. If the user is logged in, they will have access to the dashboard.
./src/app
├── api
├── dashboard
├── login
├── globals.css
└── layout.tsx
Authantication
Authentication is the process used to verify a user's identity. This action, applied when a user needs to prove who they are, is crucial for security. Authentication checks whether the information provided by the user (such as a password or biometric data) matches the information stored in the system.
Commonly used authentication methods:
- Password-based Authentication: This is the most common method, where a username and password are used. If the password entered matches the one stored in the system, the user is authenticated. However, this method can be vulnerable if weak passwords are used or passwords are stolen.
- Multi-Factor Authentication (MFA): This method uses multiple authentication factors together. For example, along with a password, an additional factor like a verification code sent to a phone number or biometric data (fingerprint, facial recognition) is used. This method is much more secure than password-based authentication.
- Token-based Authentication: In this method, the user is given a token (such as a JWT), which is used for authentication with every request made to the server. Tokens are valid for a set period and are often used in stateless applications. OAuth and JWT are popular examples of this method.
- Certificate-based Authentication: This method uses digital certificates, like X.509 certificates, for authentication. The user provides a digital certificate, which is verified by the server. It is a highly secure method, often preferred in corporate and secure applications.
- One-Time Passwords (OTP): A one-time password is generated for each session. The user logs in using an OTP sent via SMS, email, or an authentication app. Since OTPs are temporary, this method is more secure against attacks.
In server-side authentication and session management with Next.js, we will verify the user’s email and password via a mock database, then create a JWT cookie for the user's browser to switch to token-based authentication. Requesting the user’s password and email information for each HTTP request is neither practical nor secure, and storing this information for verification through a database query isn't safe either.
If we summarize the session initiation structure in a diagram:
If the user's login credentials are incorrect or cannot be verified in the database, we return an error message. If they are correct, we return a JWT session token as a cookie to the client.
Let’s start by creating a form on the login page to capture the user's email and password for authentication:
./src/components/LoginForm.tsx
"use client";
...
const LoginForm = () => {
const [showPassword, setShowPassword] = useState(false);
return (
<form className="space-y-6">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-1"
>
{"Email Address"}
</label>
<div className="relative">
<input
id="email"
name="email"
type="email"
className="input"
placeholder="[email protected]"
required
/>
<Mail className="icon" size={18} />
</div>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
{"Password"}
</label>
<div className="relative">
<input
id="password"
name="password"
type={showPassword ? "text" : "password"}
className="input"
placeholder="••••••••"
required
/>
<Lock className="icon" size={18} />
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400"
>
{showPassword ? <Eye size={18} /> : <EyeOff size={18} />}
</button>
</div>
</div>
<div>
<button type="submit" className="button">
{"Sign In"}
<ChevronRight className="ml-2" size={18} />
</button>
</div>
</form>
);
};
export default LoginForm;
We’ve created a simple login form and added a bit of flair with lucide-react icons. You can check out the detailed styles in the repository I’ve shared. Currently, the form doesn’t have any state structure to hold the data or a submit function. We’ll implement this part using the hooks introduced in React 19, and by doing so, we’ll roughly complete the front-end part that aligns with both route handlers and server action structures.
Efficient Form Handling
Until the introduction of the useFormState hook in React 18, form management in React projects heavily relied on libraries like react-hook-form, to the point where this library was often a requirement in job postings. It effectively optimized the handling of form data, while also improving performance and validation processes. However, this approach introduced additional dependencies and required each form control to be configured through hooks, making the process more complex. Particularly during form submission, managing errors, processing data, and handling the submit action involved several manual steps. With React 19, the native form management features eliminate the need for external libraries, providing a simpler and more efficient solution.
Taking advantage of these modern tools, let's implement how we can send form data to the API endpoint and handle the response in our LoginForm component using useActionState.
useActionState
The useActionState hook, introduced in React 19, makes state management for forms more effective. It integrates seamlessly with server actions, allowing for easier management of data returned from the server during form submissions.
./src/components/LoginForm.tsx
"use client";
import React, { useActionState, useState } from "react";
const LoginForm = () => {
const [showPassword, setShowPassword] = useState(false);
const [state, action, pending] = useActionState(()=>{}, undefined);
return (
<form action={action} className="space-y-6">
...
useActionState takes an initial state and an optional permalink as parameters.
- The function is triggered when the form is submitted and initiates processes such as API requests or server-side functions. It uses the previous form state and the submitted data to determine how the form will be processed.
- Initial State represents the state the form starts with when first loaded and is updated according to the results returned from the server after the form submission.
- Permalink specifies which page the browser will redirect to during form submission. If JavaScript has not yet loaded, the user will be redirected to the permalink URL. This is useful for pages with dynamic content.
The values returned by useActionState are:
- State: The current state of the form. It starts with the initial state and updates when the form is submitted.
- Action Function: Manages how the form is submitted and can be linked to the action or formAction prop of the form.
- Pending state: Unlike the useFormState hook, the useActionState hook returns a pending boolean value, setting it to true while waiting for the async function to return a state.
This hook is particularly useful for server-side actions because it manages the form's state even while waiting for the server's response, allowing you to handle errors and state in one place.
After this explanation, you can create the function to send form data via a POST request to the API endpoint as follows:
./src/lib/utils.ts
export type FormState = {
errors?: {
email?: string | string[]; //for zod errors return array
password?: string | string[];
};
message?: string;
};
export async function submitFunk(
_prevState: FormState,
formData: FormData
): Promise<FormState> {
const res = await fetch("/api/auth", {
method: "POST",
body: formData,
});
if (res.ok) {
return await res.json();
} else {
return {
errors: { email: "500 - Server Error" },
};
}
}
The submitFunk function we created to send form data to the API endpoint makes a fetch POST request and returns the response in JSON format. An important point here is defining the prevState parameter in the function. If the state type to be maintained and the formData type used in the function are not defined with two parameters, the useActionState hook will throw an error.
The reason for this error is that the useActionState hook tries to establish a connection between the current form state and the previous state, updating the state based on the values returned by the function. If a parameter representing the previous state is not defined, the hook cannot update properly, leading to a type mismatch. When FormData is not included in the current state, error messages and other return parameters cannot be processed correctly. In JavaScript, this error will only occur at runtime, resulting in the infamous "TypeError." In TypeScript, this type mismatch error will be caught during compilation when attempting to use this function within the useActionState hook, making it easier to manage.
Tips & Tricks
- The function used with the useActionState hook must accept two arguments: the current state and the form data type.
- With React 19, the useActionState hook replaces useFormState, a feature introduced in the Canary version of React DOM. In React 19, useFormState is no longer used, so make sure to switch to useActionState for managing form states in your projects.
- On the server side, using schema validation libraries like Zod helps ensure safe and type-based validation of input data. This reduces the risk of security vulnerabilities such as injection attacks. Make sure your state types and structures are accurately defined to enable fast, efficient, and secure validation.
Let's use the submitFunk function we defined with useActionState and clearly define our initial state. In the FormState type, we could have used undefined as an alternative type and left the initial state as undefined, but for clarity, we are explicitly showing the initial state below.
./src/components/LoginForm.tsx
...
const LoginForm = () => {
const [showPassword, setShowPassword] = useState(false);
/**
* We are directly using the submitFunk function inside useActionState
*/
const [state, action, pending] = useActionState(submitFunk, {
errors: {
email: "",
password: "",
},
message: "",
});
return (
<form action={action} className="space-y-6">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-1"
>
{"Email Address"}
</label>
<div className="relative">
<input
id="email"
name="email"
type="email"
className="input"
placeholder="[email protected]"
required
/>
<Mail className="icon" size={18} />
</div>
{/* If there's an email error message, we display it below the email input */}
{state?.errors?.email && (
<p className="text-red-500 text-sm mt-1">{state.errors.email}</p>
)}
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
{"Password"}
</label>
<div className="relative">
<input
id="password"
name="password"
type={showPassword ? "text" : "password"}
className="input"
placeholder="••••••••"
required
/>
<Lock className="icon" size={18} />
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400"
>
{showPassword ? <Eye size={18} /> : <EyeOff size={18} />}
</button>
</div>
{/* If there's a password error message, we display it below the password input */}
{state?.errors?.password && (
<p className="text-red-500 text-sm mt-1">{state.errors.password}</p>
)}
</div>
{/* If there's a message, we display it above the submit button
We set this up to show when the API endpoint indicates success */}
{state!.message && (
<p className="text-sm text-green-500">{state.message}</p>
)}
<div>
<button aria-disabled={pending} type="submit" className="button mt-20">
{pending ? (
<>
{"Submitting..."}
<Loader className="ml-2" size={18} />
</>
) : (
<>
{"Login"}
<ChevronRight className="ml-2" size={18} />
</>
)}
</button>
</div>
</form>
);
};
export default LoginForm;
We have integrated the submitFunk function into our LoginForm component, and by utilizing the state returned by the hook, we’ve placed error or success messages at the appropriate spots within the form. Now, the formData will be sent to the API endpoint, and the returned values will be directly usable through the state variable returned by useActionState.
useFormStatus
Additionally, we can manage the form states using the useFormStatus hook:
./src/components/LoginForm.tsx
...
)}
<div>
{/* We are adding the newly created SubmitButton component to the submit button section of the form */}
<SubmitButton />
</div>
</form>
);
};
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button aria-disabled={pending} type="submit" className="button">
{pending ? (
<>
{"Submitting..."}
<Loader className="ml-2" size={18} />
</>
) : (
<>
{"Sign In"}
<ChevronRight className="ml-2" size={18} />
</>
)}
</button>
);
}
...
This hook, used with React-DOM, returns various states related to the form as long as it is wrapped within a form parent element. For this, we utilized the useFormState hook in a separate component.
Tips & Tricks
- useFormStatus returns the values pending, data, method, and action. pending: A boolean that indicates whether the form submission process is still ongoing. data: A FormData object containing the submitted form data. method: Specifies the form's HTTP method (GET or POST). action: Returns the reference to the function assigned to the form's action prop, or returns null if no function is assigned.
- useFormStatus only works if the parent component is a form. This means if we hadn’t created SubmitButton as a separate component and instead used useFormStatus directly in the LoginForm component, it wouldn’t return any values and wouldn’t function correctly.
However, using useFormStatus solely to manage the pending state won't be functional, as we've mentioned that useActionState returns the pending state.
Auth w/Route Handlers
Let’s create the API endpoint that will take the form data and, if validation is successful, return a JWT token:
./src/app/api/auth/route.ts
...
// Route handler for POST requests
export async function POST(request: Request) {
try {
const data = await request.formData();
const errorMessage = {
email: "Invalid password or email",
password: "Invalid password or email",
};
// Validate incoming data with Zod
const parsedData = loginSchema.safeParse({
email: data.get("email"),
password: data.get("password"),
});
if (!parsedData.success) {
return NextResponse.json(
{
errors: parsedData.error.flatten().fieldErrors,
},
{ status: 401 }
);
}
...
In the API endpoint we created, we first validate the formData received in the request body using Zod’s safeParse method according to the loginSchema we defined.
Zod is a powerful library used in TypeScript projects for safe runtime data validation and type inference. Based on the schemas created, Zod's safeParse method validates user inputs or data received from HTTP/DB queries. If any input or data does not match the data type or specified regex pattern in the schema, Zod catches the invalid data and returns an error, preventing the submission. Additionally, the defined schema can easily be used as a TS type, assisting developers during the compile-time as well.
The loginSchema and loginType we used in safeParse with Zod are as follows:
./src/lib/schema.ts
import { z } from "zod";
export const loginSchema = z.object({
email: z.string().email({ message: "Please enter a valid email address." }),
password: z
.string()
.min(6, { message: "Please enter a valid password." })
});
export type loginType = z.infer<typeof loginSchema>;
In this schema, we are ensuring that the email input complies with Zod’s email validation rule and that the password input consists of at least six characters. The error messages are customized according to these rules. Additional security checks can also be added using regex.
The key point here is that input validation is done server-side. If we were to perform this validation client-side, within the form inputs, it would have no security impact. Client-side rendered forms can easily be manipulated, and validations can be bypassed. Ultimately, client-side validations are only meant to provide feedback to the user. For more effective usage examples, you can check out the schema and register form in the repository and experiment with invalid inputs.
If we continue with creating the API endpoint, after validating the formData inputs with Zod, we will need to find the user in the database by their email address and verify the password. If the inputs are correct, we should generate a JWT token and add it to the response headers.
If we continue with creating the API endpoint, after validating the formData inputs with Zod, we will need to find the user in the database by their email address and verify the password. If the inputs are correct, we should generate a JWT token and add it to the response headers.
./src/app/api/auth/route.ts
...
// Find the user in the database
const user = mockUsers.find((u) => u.email === parsedData.data.email);
if (!user) {
return NextResponse.json({ errors: errorMessage }, { status: 401 });
}
// Validate the password
const passwordMatch = await bcrypt.compare(
parsedData.data.password,
user.passwordHash
);
if (!passwordMatch) {
return NextResponse.json({ errors: errorMessage }, { status: 401 });
}
const secret = process.env.JWT_SECRET;
if (!secret) {
console.error("Configuration error: JWT Secret is missing.");
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
// Create JWT token
const secretKey = new TextEncoder().encode(secret);
const token = await new SignJWT({ id: user.id })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1h") // Token validity period
.sign(secretKey);
const production = process.env.VERCEL_ENV === "production";
// Set the JWT token as an HttpOnly cookie
const response = NextResponse.json({ message: "Login successful" });
const cookiesStore = await cookies();
cookiesStore.set("token", token, {
httpOnly: true,
secure: production,
sameSite: "strict",
maxAge: 3500,
path: "/",
});
// Return the response
return response;
} catch (error) {
console.error(error);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}
We match the email and password inputs entered by the user with the values from the database. The nuance here is that we first check the email and then the password. Regardless of where the error occurs, we return a single error message without exposing the source of the error, offering some protection against brute force attacks. In order to provide a more realistic example, we store the password the user sets during registration by hashing it with bcrypt in the mockDB, as it should be. Since this hashed value cannot be decoded, we use the bcrypt.compare method to verify the password entered in the login form.
After successfully validating the user’s email and password, we create a JWT token using the user’s ID, setting the expiration time to one hour and specifying the 'HS256' encryption algorithm.
⚠️ Caution!
The sign method from the Jose library does not come with a default algorithm (e.g., in the jsonwebtoken library, the default algorithm is HS256), and if no algorithm is specified, it throws an error. This is a precaution to avoid the use of "none" as an algorithm, but it is still prone to errors. Make sure to pay attention to the default settings of the library you are using.
We store the JWT token as a cookie named "token". Starting from Next.js 15, the cookies() API now returns a Promise, which is why we use await to call this API. With the cookiesStore variable, we access the cookie management functions returned by this API. Then, we use cookiesStore.set() to store the JWT as a cookie under the name "token".
Additionally, we use the maxAge flag when creating the cookie because if we use expires, the cookie won't be deleted until the browser is closed, even if it is no longer valid. On the other hand, maxAge will automatically delete the cookie once the time is up. In short, maxAge ensures that the cookie is automatically removed by the browser after its specified duration has passed.
Now we can test our login component. The expected action is that if the user inputs match the mockUser, a success message should be displayed, and a cookie containing a JWT with the user ID should be created. If the inputs are incorrect, it should display error messages.
It's normal that we stay on the same screen after login since we haven't implemented any redirection. However, the authentication and JWT creation are working like clockwork.
Currently, in the application, when a user directly navigates to the homepage (localhost:3232), they encounter a 404 page regardless of whether they have a session cookie or not. This is quite frustrating.
Ana sayfada 404 error
The reason for this is that the application does not have a public homepage. A public homepage with a login button in the navbar could have been set up for this purpose. However, we chose a different approach by building a fully protected application that only users who can log in can access.
We can redirect users directly to the login page by setting up Next.js config or middleware that listens to all routes. Let’s handle the redirection with Next.js config as shown below to solve this issue.
./next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: process.env.VERCEL_ENV !== "production",
async redirects() {
return [
{
source: "/",
destination: "/login",
permanent: true,
},
];
},
};
export default nextConfig;
Now, Next.js config will redirect to the login page instead of the homepage. However, when trying to access a non-existent page, it will still return a 404 error.
Auth w/Server Action
In this section, let's explore how we could handle user authentication and session token generation using a server action before setting up the page redirection and session cookie verification mechanisms.
First, we don't need a helper function to send a POST request with the fetch API, as we explained in detail in a previous article, the server action handles this behind the scenes. What remains is to directly define the operations from our API endpoint as a server action:
./src/app/actions/auth.ts
"use server";
...
export async function submitAction(
_prevState: FormState,
formData: FormData
): Promise<FormState> {
try {
const errorMessage = {
email: "Invalid password or email",
password: "Invalid password or email",
};
const parsedData = loginSchema.safeParse({
email: formData.get("email"),
password: formData.get("password"),
});
if (!parsedData.success) {
return { errors: parsedData.error.flatten().fieldErrors };
}
const user = mockUsers.find((u) => u.email === parsedData.data.email);
if (!user) {
return { errors: errorMessage };
}
const passwordMatch = await bcrypt.compare(
parsedData.data.password,
user.passwordHash
);
if (!passwordMatch) {
return { errors: errorMessage };
}
const secret = process.env.JWT_SECRET;
if (!secret) {
console.error("Configuration error: JWT Secret is missing.");
return { errors: { email: "Server error" } };
}
const secretKey = new TextEncoder().encode(secret);
const token = await new SignJWT({ id: user.id })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("155secs")
.sign(secretKey);
const production = process.env.VERCEL_ENV === "production";
const cookiesStore = await cookies();
cookiesStore.set("token", token, {
httpOnly: true,
secure: production,
sameSite: "strict",
maxAge: 150,
});
return { message: "Login successful" };
} catch (error) {
console.error(error);
return { errors: { email: "Server error" } };
}
}
This time, instead of using the NextRequest and NextResponse classes inside the function, it should be enough to create a function directly with formState and formData. We can already say that the structure we have set up looks cleaner.
Let's use the submitAction function in the form:
./src/components/LoginForm.tsx
"use client";
...
const LoginForm = () => {
const [showPassword, setShowPassword] = useState(false);
const [state, action] = useActionState(submitAction, {
errors: {
email: "",
password: "",
},
message: "",
});
return (
<form action={action} className="space-y-6">
<div>
...
As seen in the code above, we have structured the LoginForm using Server Action. Let's run a quick test;
This time, the POST requests seen in the terminal were executed as Next.js /login POST requests rather than to an API endpoint. There is no change in functionality.
Server Action vs Route Handlers
- Code Structure: In a comparison made using the same code structure and style (Prettier), the Route Handler and API POST request are managed with a total of 61 lines of code across two different files. In contrast, the Server Action offers a more simplified solution with 42 lines of code and a single file. Additionally, because it functions like a regular async function and is easier to manage, the Server Action wins in this category.
- Security: Neither structure has specific security measures like sanitization or rate limiting built-in. However, Route Handlers provide open API endpoints for handling external HTTP requests, which could potentially create more security vulnerabilities (especially regarding CSRF attacks). On the other hand, Server Actions are more isolated and can only be triggered from within the application, but they are not completely inaccessible. The fact that they are not as easily accessed as public endpoints provides a relatively more secure environment. However, additional security measures, such as sanitizing and validating the data coming from the client, are still necessary. We covered this topic in the Cookies article, see there for more details. In conclusion, since the security aspect is debatable and there is no clear superiority, we can consider this a tie in the comparison.
- Performance: In terms of performance, Server Actions and Route Handlers operate similarly, working as endpoints accessed via HTTP requests from the client. In our specific session management example, there isn't a significant difference. However, in Route Handlers, additional security precautions could lead to longer response times. An ideal comparison would involve conducting end-to-end (E2E) tests to measure response times in different scenarios, but to keep the focus on the topic, skipping this comparison for now seems more appropriate. Perhaps in another article, we could implement test processes.
- Use Case: Route Handlers are more suitable for creating APIs that integrate with external systems. For example, operations connecting to external databases or services like Stripe payment providers are more efficiently managed with Route Handlers. Server Actions, on the other hand, are primarily used for internal application processes, such as managing sessions or mutations between the client and server. Since Route Handlers support more HTTP methods and have a broader scope, they get the point in this comparison.
In this comparison, Server Action is more appropriate for our session management example with form input, offering a more compact structure. However, Route Handlers have a much wider usage purpose and, as we mentioned at the beginning of this article, they come with features such as cache management and edge runtime support. Ultimately, for handling internal requests and operations between the server and client, Server Action is more efficient for our example, so we will continue with Server Action.
Session Validation
We began our session by sending a JWT token as a cookie to the client. After the login process, we will need to redirect the user to one of the protected pages and validate their session on these pages. At the same time, let's also perform an authorization check—for example, redirect the user to a separate page for admins and another for customers. We can handle this check in two different ways: first, by using Middleware to perform this check with every HTTP request the client makes (such as updating data on the screen or switching pages) — this is called Strict Authentication. The second way is by performing the session and authorization checks within Dashboard/layout.tsx or in server actions where protected data is fetched or mutations are performed — this is called Opportunistic Authentication.
Validation w/Middleware
We briefly explained how Middleware works in Next.js in the previous article. Essentially, it is a middle layer that intercepts incoming HTTP requests and performs necessary actions such as validation or redirection for specified paths. In Next.js, the Middleware file must be at the same level as the app folder and runs at the root level, meaning it operates across the entire application. This makes it suitable for global session validation and redirection tasks; however, it can introduce additional performance overhead when detailed checks are needed for each path.
For our session validation, we will control access to the protected dashboard pages and ensure only users with a valid session cookie (JWT token) can access them.
Our file structure will be as follows:
./src/app
├── actions
├── api
├── dashboard
│ ├── admin
│ │ └── page.tsx
│ ├── customer
│ │ └── page.tsx
│ └── layout.tsx
├── layout.tsx
└── login
└── page.tsx
The function we will use for the validation process is as follows:
./src/app/actions/utils.ts
import { jwtVerify } from "jose";
import { cookies } from "next/headers";
const secretKey = new TextEncoder().encode(process.env.JWT_SECRET);
export async function verifySession() {
const cookiesStore = await cookies();
const token = cookiesStore.get("token");
if (!token) {
return false;
}
const { payload } = await jwtVerify(token.value, secretKey);
if (!payload?.id) {
return false;
}
//Eğer çerezdeki token doğrulanmışsa payload'ını dönüyoruz
return payload;
}
I placed it in the actions/utils section to separate functions that will run on the server side and should not be used on the client side. Of course, you can place it elsewhere, or you could use the brilliant masterpiece of engineering that is the server-only library to prevent its use on the client side. Now, let’s use this verify function in the middleware.
./src/middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { verifySession } from "./app/actions/utils";
export async function middleware(req: NextRequest) {
const res = NextResponse.next();
const loginRes = NextResponse.redirect(new URL("/login", req.nextUrl));
if (req.nextUrl.pathname === "/dashboard") {
const verify = await verifySession();
if (!verify) {
return loginRes;
}
}
return res;
}
We kept the middleware structure as simple as possible. This is because the middleware will run and perform checks during server action usage, Next.js static file transfers, ISR content updates, or any HTTP request. Keeping it minimal and only performing the verification for protected pages will reduce server load and make debugging easier. In this structure, the pages that are not protected continue as normal with the response. For the protected dashboard path, if the verification fails, the user is redirected to the login page, thus ensuring session control.
If the user does not have a valid cookie, the middleware redirects them to the login page, and it works as expected. However, in the current setup, when the login is successful, we aren't redirecting the user to any page, we’re just returning a message. We can handle this simply by using the useRouter() hook from next/navigation on the client side after returning the 'Login successful' message.
./src/components/LoginForm.tsx
import { useRouter } from "next/navigation";
const LoginForm = () => {
const [showPassword, setShowPassword] = useState(false);
const router = useRouter();
const [state, action] = useActionState(submitAction, {
errors: {
email: "",
password: "",
},
message: "",
} as FormState);
useEffect(() => {
// If there are no errors
// and the message is equal to the success message returned from the server
if (!state.errors && state.message === "Login successful")
router.push("/dashboard");
}, [state]);
...
In this case, we are redirecting the user directly to the dashboard page. However, if such a page does not exist, this redirection will result in a 404 error.
The 404 error is as expected, and the redirection to the dashboard after the 'Login successful' message works perfectly.
To prevent this, we can determine which page to redirect the user to based on their role. However, the redirection we’re currently doing with the useRouter hook is client-side, and managing the user's role here would not be appropriate. A more secure approach would be to handle this check server-side. Ultimately, what we need to implement is authorization.
Authorization
Authorization is a process that comes into play only when login is successful, and it typically requires verifying the user's permissions from the database. Storing the user's permissions in a cookie when the session is active can pose security risks, especially if authorization information is stored outside the database. Even though we store session data as a JWT token in the cookie, it's not safe to make decisions based on authorization data coming from the client side. As the saying goes, "Don't trust the client, even if it's your father". Additionally, preventing tampering or downgrading of authorization through a cookie is not a practical approach.
In conclusion, it's critical to always perform authorization checks on the server side and synchronize them with the database. This approach also provides an additional layer of security against "token replay" attacks, as authorization information is dynamically validated.
We can manage server-side authorization checks using two different paradigms: Strict and Opportunistic. To briefly define these paradigms:
- The Strict approach involves checking authorization with middleware before every HTTP request. It continuously checks what pages the user can view and what data requests they can make, offering tighter security.
- The Opportunistic approach only performs the authorization check before certain operations. This controls what components or content are rendered based on the user's authorization and determines what data can be fetched.
Strict Control
According to this approach, the first thing we need to do is verify the user's role by retrieving it from the database with middleware before every HTTP request. Based on the user's role, they will be redirected to a page they are authorized to access.
Authorization control diagram for every HTTP request.
According to the diagram above, the cookie in the header of each request is checked, and the JWT token extracted from the cookie is verified. If the verification is successful, a request is made to the DB using the user ID from the JWT for authorization control, and the user is redirected to the page permitted based on their role. If JWT verification fails, the user is redirected to the login page. Finally, the server prepares the relevant page and sends it as a response to the client. Ultimately, we can implement the strict authorization control shown in the schema below using middleware as follows:
./src/middleware.ts
...
export async function middleware(req: NextRequest) {
const res = NextResponse.next();
const loginRes = NextResponse.redirect(new URL("/login", req.nextUrl));
if (req.nextUrl.pathname === "/dashboard") {
const verify = await verifySession();
if (!verify) {
return loginRes;
}
const user = mockUsers.find((u) => u.id === verify.id);
if (!user) {
return loginRes;
}
if (user!.admin) {
console.log("Redirected to admin page.");
return NextResponse.redirect(new URL("/dashboard/admin", req.nextUrl));
} else {
console.log("Redirected to customer page.");
return NextResponse.redirect(new URL("/dashboard/customer", req.nextUrl));
}
}
return res;
}
We query the user ID stored in the session from the mock database, and if the user's role is admin, they are redirected to the admin page; otherwise, they are sent to the customer page.
The authorization redirection works as expected based on the user's role.
As the old saying goes, "to each their own"... But something seems off here. If you've noticed the issue, congratulations! If you almost caught it, I recommend paying close attention to the end of the video. At the end of the article, we will go over the errors and security vulnerabilities.
Additionally, there are components and POST requests that we haven’t shown the implementation for. On the dashboard page, I created a server action that fetches data from the OpenWeather API every five seconds to simulate regular data updates, with the goal of demonstrating how to configure this server action so that only users with a valid session token can access it.
We also have a navbar component that displays the weather, the user's name, and their role. You can review the code structure from the source. The relevant part here is how this navbar receives the information. It wouldn’t be an ideal solution to reuse the same navbar on every page and manually pass the Admin or Customer prop based on the page, right? Instead, the best approach would be to use a common navbar in /dashboard/layout.tsx and pass the necessary data to the navbar component based on the user's role, determining which content they should see.
./src/app/dashboard/layout.tsx
...
export default async function Layout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const key = process.env.JWT_SECRET;
const secretKey = new TextEncoder().encode(key);
const cookiesStore = await cookies();
const token = cookiesStore.get("token")?.value;
let user = null;
if (!token) {
redirect("/login");
}
const { payload } = await jwtVerify(token!, secretKey);
if (payload.id) {
/**
* Take the id of the user from JWT
* Make DB query with this id for user role
*/
user = mockUsers.find((u) => u.id === payload.id);
return (
<>
<Navbar username={user?.name || ""} isAdmin={user?.admin || false} />
{children}
</>
);
}
redirect("/login");
}
In this SSR page, we are sending only the necessary user information to our CSR Navbar component. Currently, the structure retrieves all the user's information and passes only the required data to the component props, but in a real database query, we should fetch only the necessary columns such as name and role. The validation and subsequent database query we're doing here is referred to as component-level data access.
This approach, however, is not ideal for use in a real-world project. To manage data access securely from a single place, we should implement a Data Access Layer (DAL). This layer would handle access control, and using Data Transfer Objects (DTOs), it would filter the data based on the user's permissions, ensuring that only the authorized data (e.g., name and role) is returned. Additionally, if the data being queried is coming directly from the client or URL, it should be properly validated and sanitized.
If we were using a DAL and DTO, we would call these functions in the SSR component and only return the necessary data to the component that will be rendered on the client side, managing the process securely.
Another point to note here is that we are implementing a partially opportunistic control mechanism, where data is accessed only after authorization checks. If we hadn't managed this part this way, it would have meant using the same component with static values on every page, even those where it doesn't need to be displayed. Besides being an ugly solution, it wouldn't reflect a real-world problem. Ultimately, using the layout structure to manage shared components like the navbar and sidebar and constructing the dashboard layout is a more appropriate approach.
Another point to note here is that we are implementing a partially opportunistic control mechanism, where data is accessed only after authorization checks. If we hadn't managed this part this way, it would have meant using the same component with static values on every page, even those where it doesn't need to be displayed. Besides being an ugly solution, it wouldn't reflect a real-world problem. Ultimately, using the layout structure to manage shared components like the navbar and sidebar and constructing the dashboard layout is a more appropriate approach.
Opportunistic Control
Opportunistic Control refers to a more efficient authorization mechanism where checks are made only when necessary, rather than for every HTTP request. Instead of checking permissions for every action or page load, we perform authorization checks only when certain components need to be rendered. This approach reduces the processing load on middleware and avoids frequent database queries, making it more efficient for applications with many authorized actions.
Authorization control is performed on the SSR dashboard page using Opportunistic control.
./src/middleware.ts
export async function middleware(req: NextRequest) {
const res = NextResponse.next();
const loginRes = NextResponse.redirect(new URL("/login", req.nextUrl));
if (req.nextUrl.pathname === "/dashboard") {
const verify = await verifySession();
if (!verify) {
return loginRes;
}
}
return res;
}
Now, the middleware only validates the session, and we can implement the role-appropriate dashboard structure as follows:
./src/app/dashboard/page.tsx
...
import { AdminPage, CustomerPage } from "@/components"
export default async function DashboardPage() {
const key = process.env.JWT_SECRET;
const secretKey = new TextEncoder().encode(key);
const cookiesStore = await cookies();
const token = cookiesStore.get("token")?.value;
if (!token) {
redirect("/login");
}
const { payload } = await jwtVerify(token!, secretKey);
const user = mockUsers.find((u) => u.id === payload.id);
if (!user) {
redirect("/login");
}
return (
<>
{user?.admin ? <AdminPage /> : <CustomerPage />}
</>
);
}
Instead of using a sub-page structure for the dashboard, we now send either the AdminPage or CustomerPage components (whichever is needed) to the client as a single page via SSR. Of course, we can still use shared components like the navbar and sidebar within the layout structure. Another point is that both the layout and the page perform a database query to check if the user is an admin. Although this may initially seem inefficient, it’s clearly much more efficient than doing this check and query with every HTTP request. Let’s take a look at the structure of our app directory:
./src/app
./src/app
├── actions
├── dashboard
│ ├── layout.tsx
│ └── page.tsx
├── layout.tsx
└── login
└── page.tsx
Now, we no longer have separate page redirects for customers and admins—everything happens within dashboard/page.tsx.
Authorization w/oppurtunistic control.
As we can see, now at the /dashboard endpoint, we can see separate component content for either admins or customers. However, although the nature of our issue has changed, it still persists, and this is an important detail.
Debugging
Before we get to the error I mentioned under the Authorization section, let's talk about a user experience issue during the Authentication phase. After the user logs in, they are redirected to the dashboard page using useRouter from the login page, but this only happens if the 'Login successful' message is returned from submitAction. In other words, if the user navigates back to the login page after logging in, they will not be redirected anywhere until they submit the login form and receive the success message again.
To solve this, we can add a small additional check in the middleware:
.src/middleware.ts
...
export async function middleware(req: NextRequest) {
const res = NextResponse.next();
const loginRes = NextResponse.redirect(new URL("/login", req.nextUrl));
const pathName = req.nextUrl.pathname;
if (pathName === "/login") {
const session = await verifySession();
if (session) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
// If session is not valid, continue to login page
return res;
}
...
Server Action vs Middleware
Now, let’s get to the main issue—the strange problem with the authorization process. If you noticed, a POST request was being sent every five seconds from the dashboard. We set this up to simulate a live data stream that only an authorized session could fetch. But, did this POST request continue after the session cookie was automatically deleted (once its max-age expired)? If you didn’t notice, it might be useful to revisit the GIFs in the Strict Authorization section.
After the session cookie was deleted, the POST request continued to be sent from the /dashboard/customer page. In the Opportunistic control section, the part where middleware requests were checked and the source of the POST request were the same, so this time the request was intercepted, but the interval still continued. So, let’s take a closer look at this server action POST request:
Server action POST request redirecting
The most notable part here is the Content-Type in the Response Headers of the POST request. It’s not one of the typical response types we’d expect to see in an API request, such as text/plain or application/json, nor is it text/html for an SSR page, or application/javascript for client-side rendered content. Instead, the Response Content-Type or Request Accept header shows as text/x-component, indicating that this data will be used within a React Server Component (RSC) structure.
Magnificent article about RSC work logic.
What does this mean?
When a server action is used on the client side, the POST request operates in the same way as an RSC. The redirect we perform in the middleware using the NextResponse class is a low-level HTTP redirection, which tells the browser to make this request to the login page with a 307 Temporary Redirect status. While the POST request is indeed sent to the login page and appears successful with a 200 status code, nothing actually changes, and the client is not redirected to the login page.
There are two reasons for this:
- The request sent to the login page is a POST request.
- This request is made within the RSC structure, meaning even if a GET request were made, a response in text/html format wouldn’t trigger a full page reload.
What about the logout button and login redirect?
Ah, you’ve raised a very valid point! If we recall the Logout server action, its structure looked something like this:
./src/app/actions/auth.ts
...
import { redirect } from "next/navigation";
...
export const logout = async () => {
const cookiesStore = await cookies();
cookiesStore.delete("token");
redirect("/login");
};
This function uses the redirect function from next/navigation. First, it communicates to the browser with a 303 status, indicating that the operation is complete but the response should be found elsewhere, and it uses the x-action-redirect header to guide the browser. The browser then sends a GET request to the login page, and the response from the Login page comes as text/x-component. However, since this format is not suitable for re-rendering the entire page, the URL is appended with the ?_rsc=v3pub meta tag to force the page to re-render, effectively redirecting the client to the Login page.
If we manually implement this structure with NextResponse.redirect in middleware, we could trigger a GET request to /login?_rsc=v3pub after a POST request with a 303 status. However, since the response from this would be in text/html instead of text/x-component under the RSC structure, the page would still not refresh or redirect correctly on the client side.
Where’s the hotfix?
Yes, we still have the issue of the infinite POST requests. Even if the cookie on the client is deleted and this POST request doesn’t contain the cookie or JWT token, the client’s session continues, and the POST requests keep being sent. Let’s come up with a solution:
.src/middleware.ts
...
if (pathName === "/dashboard") {
const verify = await verifySession();
if (!verify) {
if (req.method === "POST") {
return new NextResponse(
JSON.stringify({ message: "POST is not allowed" }),
{
status: 405, // 405 Method Not Allowed
headers: {
"Content-Type": "application/json",
},
}
);
}
return loginRes;
}
}
...
If the session cannot be validated as described above and a POST request is made, we can return a response that blocks the request. This would prevent POST requests from reaching the server from users whose sessions have expired. However, the client would not be redirected back to the /login page—they would just stare at the screen, while the browser’s JS structure continues to send the request on an interval. It’s a solution that’s very server-friendly, but not at all user-friendly.
Of course, we could catch this response on the client side and use useRouter to handle the redirection, but that’s not ideal either—we want the redirection to be handled from the server side.
So, what if we do it this way:
./src/middleware.ts
...
const res = NextResponse.next();
...
if (pathName === "/dashboard") {
const verify = await verifySession();
if (!verify) {
if (req.method === "POST" && req.headers.get("next-action")) {
return res;
}
return loginRes;
}
}
...
What do you mean, 'res'? Who are you to say 'res'? Allowing a POST request to pass? What madness is this! But before you call me reckless and foolish, let me explain further...
./src/app/actions/getWeatherData.ts
"use server";
...
import { redirect } from "next/navigation";
...
export async function getWeatherData(
city: WeatherInpType
): Promise<WeatherData | null> {
const verify = await verifySession();
if (!verify) {
redirect("/login");
}
...
In this case, we are first running the verify check before using the server action, and with next/navigation, we are redirecting any client that makes a request with an expired session directly to the login page.
Fetching Data with Server Action?
We demonstrated that using a server action for data fetching will not work properly in middleware redirection, but the main question is whether server actions can be used for fetching data or retrieving up-to-date data as in our example. Next.js doesn’t recommend using server actions for data fetching; in fact, they address the topic of server actions under the Server Actions and Mutations section.
The first reason for this is that the server action structure is designed for form submissions and mutations, which is why it only works with POST requests. Another reason is that when a server action like getWeatherData is executed on each client, the data will be processed with a new POST request and a response will be returned to the client.
Even if we revalidate or cache this request using the Fetch API, the server will still process each POST request coming from the client, including parameter verification and safe parsing of the incoming data. These processes can be optimized by caching the response using Next.js' unstable_cache, which would return the same response during the revalidation period. This would reduce server load by ensuring that the same response is returned for POST requests within the cache duration, while still verifying the client parameters and safe parsing the external data.
For fetching, it is recommended to use structures like RSC, ISR, or SSR on the server side, and for periodic data fetching on the client side, libraries like SWR or React Query are suggested. Of course, if the data being fetched is not public, it will be necessary to create an API endpoint with route handlers and establish a connection with session control. The example here is somewhat debatable. Without creating a public API endpoint or implementing client-side code that sends requests to and handles errors from that endpoint, the convenience of calling the server action like a function on the client side is a plus. Additionally, the unstable_cache function somewhat resolves caching issues, and the lack of dependency on external libraries is another advantage.
In conclusion, it would be more appropriate to examine this topic in detail under Next.js' data fetching or cache management and find the optimal solution. In relation to our topic, we have discussed how to catch and block a server action POST request in middleware or how to redirect the client to a login page using this server action data fetch example. Ultimately, a mutation POST request coming from the client should either be verified before the server action runs or be blocked in middleware, as shown above, if there is no valid session token.
Conclusion
In this article, we discussed how to implement secure and performant session management using a stateless-session approach with Next.js and React 19. We explored the advantages of storing JWT tokens in cookies to reduce the load on the server, while also highlighting the security risks associated with this method.
We provided tips on the correct usage of the useActionState hook introduced in React 19 for form management, showing how to set up effective form state management without relying on external libraries. We also compared Server Actions and Route Handlers to evaluate which approach is more suitable for session management and authentication processes.
In terms of authorization, we examined the pros and cons of the Strict and Opportunistic approaches, emphasizing the importance of choosing the right method based on your application's needs. We explored how to perform session validation with Middleware and looked into potential issues that might arise during the process.
Additionally, we discussed the challenges of using server actions for data fetching and reviewed the recommended approaches by Next.js. In particular, we touched on the negative impact of unnecessary POST requests to the server in terms of performance and security, and how these should be managed.
In conclusion, we have once again seen the critical importance of balancing security and performance in session management and authorization. With the new features provided by Next.js and React 19, managing these processes has become much easier and more efficient. However, security remains a key aspect that requires careful attention. Even when using third-party services for authentication and session tokens, it's essential to implement proper measures for managing restricted endpoints or server actions. For more detailed code structure, feel free to check out the repository.
I've tried to create a beginner-friendly and easy-to-understand resource. Please don't hesitate to reach out with any feedback or suggestions.
Happy coding!
References
- https://developer.mozilla.org/en-US/docs/Web/HTTP
- https://nextjs.org/docs
- https://19.react.dev/reference/react
- https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html
- https://self-issued.info/docs/draft-ietf-oauth-json-web-token.html
- https://www.youtube.com/watch?v=N_sUsq_y10U
- https://nextjs.org/blog/security-nextjs-server-components-actions
- https://vercel.com/blog/understanding-react-server-components