Build a full role-based access control system with V0 featuring custom roles, granular permissions, a permission matrix editor, middleware-level enforcement, and permission caching. You'll create a multi-tenant RBAC that integrates with Supabase RLS policies for database-level security — all in about 2-4 hours.
What you're building
Any multi-user SaaS app needs access control — who can view what, who can edit, who can delete. Without a permission system, you either give everyone full access (security risk) or hardcode roles (inflexible). A proper RBAC system lets admins define custom roles and assign granular permissions.
V0 generates the permission matrix UI, role management pages, and middleware enforcement from prompts. Supabase handles the permission data and enforces access at the database level via RLS policies that reference a check_permission RPC function.
The architecture uses a Supabase RPC function as the single source of truth for permissions, RLS policies that call this function, Next.js middleware for route-level gating, and unstable_cache for performance so permission checks don't hit the database on every request.
Final result
A complete RBAC system with custom roles, granular permissions, a visual permission matrix editor, middleware enforcement, database-level RLS, and cached permission checks.
Tech stack
Prerequisites
- A V0 account (Premium recommended for complex multi-file generation)
- A Supabase project (free tier works — connect via V0's Connect panel)
- An existing multi-tenant app or user management system to add permissions to
- Supabase Auth configured with user accounts
Build steps
Set up the RBAC database schema with RPC function
Create the roles, permissions, role_permissions junction, and user_roles tables. Then create a check_permission RPC function that verifies if a user has a specific permission in an organization.
1// Paste this prompt into V0's AI chat:2// Create a Supabase schema for role-based access control:3// 1. roles table: id (uuid PK), org_id (uuid FK), name (text — 'owner', 'admin', 'editor', 'viewer' or custom), description (text), is_system (boolean DEFAULT false)4// 2. permissions table: id (uuid PK), resource (text — 'projects', 'users', 'billing', 'settings'), action (text — 'create', 'read', 'update', 'delete'), description (text), UNIQUE(resource, action)5// 3. role_permissions junction: role_id (uuid FK), permission_id (uuid FK), PRIMARY KEY(role_id, permission_id)6// 4. user_roles table: user_id (uuid FK), org_id (uuid FK), role_id (uuid FK), assigned_by (uuid FK), assigned_at (timestamptz), PRIMARY KEY(user_id, org_id)7// Create an RPC function check_permission(p_user_id uuid, p_org_id uuid, p_resource text, p_action text) that returns boolean by joining user_roles → role_permissions → permissions.8// Seed 4 default roles (owner, admin, editor, viewer) with appropriate permissions.9// Seed 16 permissions (4 resources x 4 actions).Pro tip: Store SUPABASE_SERVICE_ROLE_KEY in V0's Vars tab without NEXT_PUBLIC_ prefix. The middleware needs this key to check permissions server-side, bypassing RLS.
Expected result: Four tables, 16 permissions, 4 default roles with pre-assigned permissions, and a check_permission RPC function that returns true/false.
Build the permission matrix editor
Create an admin page showing a Table with permissions as rows and roles as columns. Each intersection has a Checkbox that toggles whether the role has that permission.
1'use client'23import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'4import { Checkbox } from '@/components/ui/checkbox'5import { Badge } from '@/components/ui/badge'6import { togglePermission } from '@/app/actions/permissions'78type Permission = { id: string; resource: string; action: string }9type Role = { id: string; name: string; is_system: boolean }10type RolePermission = { role_id: string; permission_id: string }1112export function PermissionMatrix({13 roles, permissions, rolePermissions,14}: {15 roles: Role[]16 permissions: Permission[]17 rolePermissions: RolePermission[]18}) {19 const hasPermission = (roleId: string, permId: string) =>20 rolePermissions.some((rp) => rp.role_id === roleId && rp.permission_id === permId)2122 const grouped = permissions.reduce((acc, p) => {23 if (!acc[p.resource]) acc[p.resource] = []24 acc[p.resource].push(p)25 return acc26 }, {} as Record<string, Permission[]>)2728 return (29 <Table>30 <TableHeader>31 <TableRow>32 <TableHead>Permission</TableHead>33 {roles.map((role) => (34 <TableHead key={role.id} className="text-center">35 <Badge variant={role.is_system ? 'default' : 'secondary'}>{role.name}</Badge>36 </TableHead>37 ))}38 </TableRow>39 </TableHeader>40 <TableBody>41 {Object.entries(grouped).map(([resource, perms]) => (42 perms.map((perm) => (43 <TableRow key={perm.id}>44 <TableCell className="font-medium">45 {resource}.{perm.action}46 </TableCell>47 {roles.map((role) => (48 <TableCell key={role.id} className="text-center">49 <Checkbox50 checked={hasPermission(role.id, perm.id)}51 onCheckedChange={() => togglePermission(role.id, perm.id)}52 disabled={role.name === 'owner'}53 />54 </TableCell>55 ))}56 </TableRow>57 ))58 ))}59 </TableBody>60 </Table>61 )62}Expected result: A matrix Table with permissions on rows and roles on columns. Clicking a Checkbox toggles the permission for that role. Owner role checkboxes are disabled.
Create the middleware for route-level permission enforcement
Build a Next.js middleware that checks permissions before allowing access to admin routes. It uses the Supabase RPC function with the service role key.
1import { NextRequest, NextResponse } from 'next/server'2import { createClient } from '@supabase/supabase-js'34const supabase = createClient(5 process.env.NEXT_PUBLIC_SUPABASE_URL!,6 process.env.SUPABASE_SERVICE_ROLE_KEY!7)89const routePermissions: Record<string, { resource: string; action: string }> = {10 '/admin/users': { resource: 'users', action: 'read' },11 '/admin/settings': { resource: 'settings', action: 'read' },12 '/admin/billing': { resource: 'billing', action: 'read' },13 '/admin/roles': { resource: 'users', action: 'update' },14}1516export async function middleware(req: NextRequest) {17 const path = req.nextUrl.pathname18 const permission = Object.entries(routePermissions).find(([route]) =>19 path.startsWith(route)20 )?.[1]2122 if (!permission) return NextResponse.next()2324 const userId = req.cookies.get('user_id')?.value25 const orgId = req.cookies.get('org_id')?.value2627 if (!userId || !orgId) {28 return NextResponse.redirect(new URL('/login', req.url))29 }3031 const { data: hasAccess } = await supabase.rpc('check_permission', {32 p_user_id: userId,33 p_org_id: orgId,34 p_resource: permission.resource,35 p_action: permission.action,36 })3738 if (!hasAccess) {39 return NextResponse.redirect(new URL('/unauthorized', req.url))40 }4142 return NextResponse.next()43}4445export const config = {46 matcher: '/admin/:path*',47}Expected result: Accessing /admin/users checks if the current user has users.read permission. Unauthorized users are redirected to /unauthorized.
Add permission caching for performance
Create a cached permission checker that stores the user's full permission set with a 5-minute TTL. Invalidate the cache when roles are changed via revalidateTag.
1import { createClient } from '@/lib/supabase/server'2import { unstable_cache } from 'next/cache'34export const getUserPermissions = unstable_cache(5 async (userId: string, orgId: string) => {6 const supabase = await createClient()78 const { data } = await supabase9 .from('user_roles')10 .select('roles(role_permissions(permissions(resource, action)))')11 .eq('user_id', userId)12 .eq('org_id', orgId)13 .single()1415 const permissions = new Set<string>()16 const rolePerms = (data as any)?.roles?.role_permissions ?? []17 for (const rp of rolePerms) {18 if (rp.permissions) {19 permissions.add(`${rp.permissions.resource}.${rp.permissions.action}`)20 }21 }2223 return Array.from(permissions)24 },25 ['user-permissions'],26 { revalidate: 300, tags: ['permissions'] }27)2829export async function hasPermission(30 userId: string,31 orgId: string,32 resource: string,33 action: string34) {35 const perms = await getUserPermissions(userId, orgId)36 return perms.includes(`${resource}.${action}`)37}Pro tip: When a role is changed, call revalidateTag('permissions') in the Server Action to invalidate the cache. This ensures permission changes take effect within seconds, not minutes.
Expected result: Permission checks use a 5-minute cache. Changing a role triggers cache invalidation via revalidateTag('permissions').
Build the role and member management pages
Create admin pages for managing roles and assigning them to users. The roles page shows existing roles with edit capability, and the members page has a Table with role assignment Select.
1// Paste this prompt into V0's AI chat:2// Build two admin pages for permission management:3// 1. Roles page at app/admin/roles/page.tsx:4// - Table listing all roles with columns: Name (Badge), Description, Permissions Count, System Badge, Actions5// - "Create Custom Role" Button opens Dialog with Form: Input for name, Textarea for description6// - Click a role row to navigate to app/admin/roles/[id]/page.tsx showing the PermissionMatrix filtered to that role7// - System roles (owner, admin) cannot be deleted — disable the Delete button8// 2. Members page at app/admin/members/page.tsx:9// - Table with columns: Avatar, Name, Email, Role Select (dropdown of available roles), Assigned By, Assigned Date10// - Changing the Select calls a Server Action to update user_roles and revalidateTag('permissions')11// - AlertDialog for confirming role changes on admin/owner roles12// - "Invite Member" Button at top13// Use shadcn/ui Table, Select, Dialog, Badge, AlertDialog.Expected result: A roles management page with Table and create Dialog, and a members page with role assignment Select dropdowns. Role changes invalidate the permission cache.
Complete code
1import { createClient } from '@/lib/supabase/server'2import { unstable_cache } from 'next/cache'34export const getUserPermissions = unstable_cache(5 async (userId: string, orgId: string) => {6 const supabase = await createClient()78 const { data } = await supabase9 .from('user_roles')10 .select(11 'roles(role_permissions(permissions(resource, action)))'12 )13 .eq('user_id', userId)14 .eq('org_id', orgId)15 .single()1617 const permissions = new Set<string>()18 const rolePerms =19 (data as any)?.roles?.role_permissions ?? []2021 for (const rp of rolePerms) {22 if (rp.permissions) {23 permissions.add(24 `${rp.permissions.resource}.${rp.permissions.action}`25 )26 }27 }2829 return Array.from(permissions)30 },31 ['user-permissions'],32 { revalidate: 300, tags: ['permissions'] }33)3435export async function hasPermission(36 userId: string,37 orgId: string,38 resource: string,39 action: string40): Promise<boolean> {41 const perms = await getUserPermissions(userId, orgId)42 return perms.includes(`${resource}.${action}`)43}4445export async function requirePermission(46 userId: string,47 orgId: string,48 resource: string,49 action: string50) {51 const allowed = await hasPermission(userId, orgId, resource, action)52 if (!allowed) {53 throw new Error(`Permission denied: ${resource}.${action}`)54 }55}Customization ideas
Add permission inheritance
Allow roles to inherit permissions from parent roles. For example, 'admin' inherits all 'editor' permissions plus its own additional ones.
Add field-level permissions
Extend permissions to control which fields users can see or edit on specific resources, like hiding salary data from non-HR roles.
Add audit logging
Log every permission check and role change to an audit table. Create a timeline view showing who changed what permissions and when.
Add temporary permissions
Allow assigning permissions with an expiration time. Useful for granting temporary access to contractors or time-limited projects.
Common pitfalls
Pitfall: Checking permissions only in the frontend UI
How to avoid: Always enforce permissions server-side in middleware, API routes, and Server Actions. Frontend checks are for UX only (hiding irrelevant buttons).
Pitfall: Calling check_permission RPC on every single request without caching
How to avoid: Cache the user's permission set using unstable_cache with a 5-minute TTL. Invalidate with revalidateTag('permissions') when roles change.
Pitfall: Using NEXT_PUBLIC_ prefix for SUPABASE_SERVICE_ROLE_KEY
How to avoid: Store SUPABASE_SERVICE_ROLE_KEY in V0's Vars tab without any prefix. It is only available in server-side code.
Best practices
- Use a Supabase RPC function as the single source of truth for permission checks — called by both RLS policies and application code
- Cache permission sets with unstable_cache and revalidateTag for performance without sacrificing real-time accuracy on role changes
- Enforce permissions server-side in middleware, API routes, and Server Actions — never rely solely on frontend UI hiding
- Store SUPABASE_SERVICE_ROLE_KEY in V0's Vars tab without NEXT_PUBLIC_ prefix for middleware permission checks
- Protect system roles (owner, admin) from deletion or modification to prevent accidental lockouts
- Use Design Mode (Option+D) to visually adjust the permission matrix Checkbox spacing and Badge colors at zero credit cost
- Use RLS policies that reference check_permission so even direct Supabase client access is gated
AI prompts to try
Copy these prompts to build this project faster.
I'm building a role-based access control system with Next.js App Router and Supabase. I need: 1) Roles with granular permissions (resource + action), 2) A permission matrix editor, 3) Supabase RPC function for permission checking used in RLS policies, 4) Next.js middleware for route-level enforcement, 5) Permission caching with invalidation. Help me design the schema and the caching strategy.
Create a permission caching system for Next.js App Router using unstable_cache. Requirements: 1) Cache the full permission set for a user+org combination with a 5-minute TTL, 2) Export hasPermission(userId, orgId, resource, action) that checks the cached set, 3) Tag the cache with 'permissions' so it can be invalidated, 4) In the role change Server Action, call revalidateTag('permissions') to flush the cache. The cache should store permissions as an array of 'resource.action' strings for fast lookup.
Frequently asked questions
How does the permission matrix work?
The matrix shows permissions (rows) and roles (columns). Each cell is a Checkbox. Checking it adds the permission to the role via the role_permissions junction table. The check_permission RPC function joins through this table to verify access.
How does permission caching work?
On first request, the user's full permission set is fetched from Supabase and cached for 5 minutes using unstable_cache. Subsequent requests use the cached set. When a role is changed, revalidateTag('permissions') invalidates the cache immediately.
What V0 plan do I need?
V0 Premium ($20/month) is recommended due to the number of complex files — permission matrix, role management, member management, middleware, and caching utility.
Can I add custom roles beyond the defaults?
Yes. The Create Custom Role Dialog lets admins define new roles with a name and description, then assign permissions via the matrix. Custom roles have is_system=false and can be freely modified or deleted.
How does this integrate with Supabase RLS?
RLS policies on application tables reference the check_permission RPC function. For example, a policy on 'projects' checks check_permission(auth.uid(), org_id, 'projects', 'read'). This means even direct Supabase client access is permission-gated.
Can RapidDev help build a custom RBAC system?
Yes. RapidDev has built 600+ apps including enterprise platforms with complex RBAC, ABAC, and multi-tenant permission systems. Book a free consultation to discuss your access control requirements.
How do I deploy this?
Click Share > Publish in V0. Add SUPABASE_SERVICE_ROLE_KEY in Vercel Dashboard (no NEXT_PUBLIC_ prefix). The middleware will automatically enforce permissions on all /admin routes in production.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation