Building RBAC for Six User Roles in One Next.js App
If-else chains in page components break at four roles and enforce nothing at the API layer. Two enforcement points โ middleware prefix guards and per-server-action requireAuth checks โ plus a critical Supabase gotcha: reading role from user_metadata lets users escalate their own privileges.
The first implementation had if-else chains in each page component. Check the session, read the role, render the right content. It worked for two roles. By the time I added the fourth role, each page component had a wall of conditional logic that was easy to get wrong.
The bigger problem was the API layer. The role check in the UI meant nothing for direct API calls. A student who found the enrollment approval API URL could call it with a valid session token and get a 200 response, because the server action just ran. No role check on the server.
The portal has six roles: super_admin, admin, registrar, finance, faculty, and student. Plus applicant as a pre-enrollment state. Each role should see different routes, different data, and take different actions. None of that can be enforced only in the UI.
The Two-Layer Model
Layer one is middleware route guards. Prefix-based, evaluated on every request before any page code runs.
The route prefixes map directly to role sets:
/admin/*โ onlysuper_admin,admin/finance/*โ onlyadmin,finance/registrar/*โ onlyadmin,registrar/faculty/*โ onlyfaculty,admin/my-enrollment/*โ onlystudent/applicant/*โ onlyapplicant
Middleware checks the session role against the required set for the matched prefix. Wrong role gets a redirect to their appropriate landing page, not a 403. I will come back to why redirect and not 403.
Layer two is server action role checks. Every exported server action starts with a requireAuth call before doing anything else. No exceptions.
async function approveEnrollment(enrollmentId: string) {
await requireAuth(["super_admin", "admin", "registrar"]);
// actual logic starts here
}requireAuth reads the session, checks the role, and throws if the caller does not have a permitted role. The throw bubbles up as an error response. The function never executes.
Both layers are necessary. Middleware blocks navigation but Next.js server actions are callable directly with a fetch request and a valid session cookie. A student who reads the network tab and finds the server action URL can call it independently of the UI. Layer two catches that. Layer one is convenience and user experience. Layer two is the actual security boundary.
The app_metadata Mistake
Supabase gives each user two metadata fields: app_metadata and user_metadata. They look similar. They are not.
user_metadata is writable by the user through the Supabase client. A user can call supabase.auth.updateUser({ data: { role: 'admin' } }) and update their own user_metadata. The client SDK allows it.
app_metadata is only writable via the Supabase admin client using the service role key. The service role key never goes near the browser. Users cannot write to app_metadata.
The first implementation read role from user_metadata. A student with a browser console and the Supabase client library could set their own role to admin and the system would accept it. We caught this in a security review before it reached production users, but it was a real vulnerability.
The fix: always read role from app_metadata. Set it exclusively through the admin client in server-only code, never exposed to the browser. This is not prominently documented in Supabase's getting started guides. It is in the docs, but not highlighted as a security requirement.
Cost of that mistake: one security review cycle and about a day of refactoring. The fix itself was small โ change which metadata field you read. The cost was the time spent finding it.
DB Overrides for Runtime Permission Changes
Hard-coded role arrays work for 95% of cases. The other 5% is where a specific person needs temporary access to something outside their normal role scope.
We had this happen during an audit: the registrar needed to view finance reports for a week without being assigned the finance role permanently. A permanent role change would give them access to write operations they should not have. A code deployment to change the permission arrays was not the right answer either.
I added a system_permissions table: (user_id, resource, action, granted, expires_at). The permission check logic reads this table after checking the role-based defaults. A row in this table can grant or revoke specific permissions for a specific user.
The check order: DB override wins over code defaults. If a row exists for that user and resource, use it. If not, fall back to the role-based permission set.
For the audit: one row in system_permissions granting the registrar read access to finance reports with an expiry date. No role change, no code deployment. Expired automatically when the audit week ended.
The Applicant Edge Case
Applicants are users who have submitted an application but have not been enrolled yet. They have a valid account with a session. They should only see their application status page.
The middleware check for applicants redirects instead of returning 403. If an applicant hits /registrar/dashboard, they get redirected to /applicant/status, not blocked with a 403 error.
The reason: a 403 tells the user they are authenticated but forbidden from this resource. That is useful feedback for a developer debugging permissions. For an applicant who clicked a link sent by mistake, a 403 is confusing. A redirect to where they should actually be is less disorienting and less likely to generate a support request.
This is a judgment call. For internal admin routes where the audience is staff who understand the permission model, a 403 might be more appropriate. For student-facing routes where the user might not know anything about how roles work, a quiet redirect serves them better.
What Maintenance Actually Looks Like
Initial implementation was about two days. Adding a new permission check to an existing server action takes around 10 minutes. The requireAuth wrapper is small enough to drop into any function.
The real maintenance cost is role transitions. When a staff member changes departments and their role changes, there are two steps: update app_metadata via the admin client, and invalidate their current session.
Session invalidation is necessary because the session token caches the role at the time of login. A session started as finance staff will still carry the finance role in its cached claims even after you update app_metadata. Forcing logout on role change means the next login picks up the new role.
We had one incident where a staff member who moved to a different department retained access to their old finance reports for the duration of their session. No data was misused. But the gap between role update and actual access change was longer than I wanted. The session invalidation step is now part of the documented role change procedure.
Found this useful?
Share it with someone who'd appreciate it.
https://wardvisual.com/blogs/rbac-six-roles-nextjs-supabase

@wardvisual ยท ๐ต๐ญ Dasmarinas City, Cavite PH
Full-stack engineer. Business systems, database optimization, and operations software.