Implement server-side role-based rendering using a multi-layered approach: middleware for early routing decisions, Server Components with role verification, and experimental forbidden() for 403 responses, while architecturally separating code by role to prevent client-side exposure
Implementing role-based rendering without exposing protected content requires shifting authorization completely to the server. The fundamental principle is that unauthorized users should never receive the JavaScript code or markup for protected routes. This is achieved through a defense-in-depth strategy: middleware provides fast edge-level filtering, Server Components perform database-backed permission checks, and architectural patterns ensure that admin-only code never reaches unauthorized clients. Critical vulnerabilities like CVE-2025-29927 (CVSS 9.1) have demonstrated that relying solely on middleware can be dangerous, making layered validation essential .
Never rely on client-side checks: Client-side role checks provide zero security value—attackers can modify client JavaScript to bypass them entirely .
Defense in depth: Implement authorization at multiple layers (middleware, Server Components, API routes) so that a bypass in one layer doesn't compromise security .
Fail securely: Default to denying access and returning 404 for unauthorized requests to prevent information leakage about protected routes .
Code separation: Architecturally separate code for different roles to prevent admin components from being included in user bundles .
Middleware provides the first line of defense, running at the edge before requests reach your pages. It can quickly validate JWTs and redirect unauthenticated users, but should not be the only protection. The CVE-2025-29927 vulnerability showed that attackers could bypass middleware entirely by manipulating headers, so middleware checks must be complemented by deeper validation . Use middleware for fast, coarse-grained routing decisions like redirecting logged-out users or rewriting requests based on basic role claims .
The critical layer of protection happens in Server Components themselves. After middleware, the request reaches your page component where you must verify authorization again—this time with full database access. The Next.js documentation emphasizes that authorization should be enforced at the data access layer, where you can verify permissions against your database rather than just token claims . This prevents token tampering and ensures that revoked permissions are immediately enforced.
Next.js 15.1 introduced an experimental forbidden() function that throws an error rendering a 403 page . This provides a semantic way to handle authorization failures. To use it, enable the authInterrupts flag in your config. The forbidden() function can be called in Server Components, Server Actions, and Route Handlers, and pairs with a custom forbidden.js file for branded error pages .
The final and most critical layer is authorization at the data level. Even if a user bypasses page-level checks, they should never be able to access data they don't own. Create a dedicated Data Access Layer (DAL) that enforces permissions for every database query . This ensures that even if an attacker finds a way to call internal functions, authorization is still enforced.
A critical but often overlooked aspect is preventing admin code from reaching client bundles. Even if you protect routes, if admin components are imported in shared layouts, their JavaScript may still be sent to users . The solution is architectural separation: keep admin pages in their own route group with separate layouts, and never import admin-only code in user-facing components. Use route groups like (admin) and (user) to create clear boundaries .
Always revalidate authorization inside Server Actions: Even if the UI hides buttons, users can craft requests directly to actions .
Use the 'use server' directive with role checks at the beginning of each action .
Never trust client-provided data for authorization decisions—always check the session.
Return generic errors to avoid leaking information about why authorization failed .
A important security consideration is whether to return 404 (Not Found) or 403 (Forbidden) for unauthorized access. Returning 404 for protected routes provides better security through obscurity—attackers cannot distinguish between non-existent routes and protected ones . However, returning explicit 403 errors can be useful for debugging and providing clear user feedback. The experimental forbidden() function provides a semantic way to return 403 when you want explicit denial .
Use middleware for coarse-grained edge filtering but never as sole protection
Verify authorization in every Server Component using database-backed sessions
Create a Data Access Layer that enforces permissions on all queries
Architecturally separate code for different roles to prevent bundle leakage
Validate authorization in every Server Action
Consider using forbidden() for explicit 403 responses
Upgrade Next.js to patched versions (≥12.3.5, ≥13.5.9, ≥14.2.25, ≥15.2.3) to fix CVE-2025-29927