Crunchypix
Crunchypix

Loading...

Next.js Session Manangement

10/12/2024

In this article, we explore how to manage sessions using effective and modern technologies, aimed at those with basic React knowledge, who are new to Next.js technologies, or who lack backend experience. We cover important points on Route Handlers and JWT, examining the implementation of Authentication, Authorization, middleware, and opportunistic validation approaches, as well as how to handle potential issues.
next.js session manangement
Crunchypix
Crunchypix

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.

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.
jwt
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.
A useful open-source book on Cryptography:
https://cryptobook.nakov.com/

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:
Login page credentials auth flow
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:
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.
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:
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.
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.
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:
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.
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:
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:
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.
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.
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.
Login
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.
404 error
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.
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:
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:
As seen in the code above, we have structured the LoginForm using Server Action. Let's run a quick test;
Server Action Login form
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:
The function we will use for the validation process is as follows:
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.
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.
middleware redirection
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.
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.
Dashboard redirection 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.
Strict authorization flow
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:
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.
Athorization control & redirecting
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.
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.

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.
Oppurtunistic authorization flow
Authorization control is performed on the SSR dashboard page using Opportunistic control.
Now, the middleware only validates the session, and we can implement the role-appropriate dashboard structure as follows:
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:
Now, we no longer have separate page redirects for customers and admins—everything happens within dashboard/page.tsx.
Athorization Oppurtunistic control & redirecting
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:

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
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:
  1. The request sent to the login page is a POST request.
  2. 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:
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:
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:
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...
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
Share

Recommended Posts


nextjs cookie manangement

Next.js Cookie Management

app routerserver actionstypescriptcookiesmiddlewarenext.js
11/09/2024
This article offers a beginner friendly guide for those who have no experience with backend development or Next.js. We explore what cookies are and how to manage them securely. By explaining the structure and features of Next.js, we walk through how to use server actions and the cookies() method from the next/headers, as well as how to capture cookies with Middleware.