Skip to main content
RapidDev - Software Development Agency

How to Build User permission management with V0

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'll build

  • Permission matrix editor with shadcn/ui Table showing roles as columns and permissions as rows with Checkbox toggles
  • Custom role creation Dialog with name, description, and permission assignment
  • User-role assignment on the members page with Select dropdown and Badge role labels
  • Supabase RPC function check_permission(user, org, resource, action) used in RLS policies across all tables
  • Next.js middleware.ts that gates admin routes by checking permissions server-side
  • Permission caching with unstable_cache and 5-minute TTL, invalidated on role changes via revalidateTag
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced10 min read2-4 hoursV0 Premium or higherApril 2026RapidDev Engineering Team
TL;DR

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

V0AI Code Generator
Next.jsFull-Stack Framework
Tailwind CSSStyling
shadcn/uiComponent Library
SupabaseDatabase

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

1

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.

prompt.txt
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.

2

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.

components/permission-matrix.tsx
1'use client'
2
3import { 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'
7
8type Permission = { id: string; resource: string; action: string }
9type Role = { id: string; name: string; is_system: boolean }
10type RolePermission = { role_id: string; permission_id: string }
11
12export 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)
21
22 const grouped = permissions.reduce((acc, p) => {
23 if (!acc[p.resource]) acc[p.resource] = []
24 acc[p.resource].push(p)
25 return acc
26 }, {} as Record<string, Permission[]>)
27
28 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 <Checkbox
50 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.

3

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.

middleware.ts
1import { NextRequest, NextResponse } from 'next/server'
2import { createClient } from '@supabase/supabase-js'
3
4const supabase = createClient(
5 process.env.NEXT_PUBLIC_SUPABASE_URL!,
6 process.env.SUPABASE_SERVICE_ROLE_KEY!
7)
8
9const 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}
15
16export async function middleware(req: NextRequest) {
17 const path = req.nextUrl.pathname
18 const permission = Object.entries(routePermissions).find(([route]) =>
19 path.startsWith(route)
20 )?.[1]
21
22 if (!permission) return NextResponse.next()
23
24 const userId = req.cookies.get('user_id')?.value
25 const orgId = req.cookies.get('org_id')?.value
26
27 if (!userId || !orgId) {
28 return NextResponse.redirect(new URL('/login', req.url))
29 }
30
31 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 })
37
38 if (!hasAccess) {
39 return NextResponse.redirect(new URL('/unauthorized', req.url))
40 }
41
42 return NextResponse.next()
43}
44
45export 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.

4

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.

lib/permissions.ts
1import { createClient } from '@/lib/supabase/server'
2import { unstable_cache } from 'next/cache'
3
4export const getUserPermissions = unstable_cache(
5 async (userId: string, orgId: string) => {
6 const supabase = await createClient()
7
8 const { data } = await supabase
9 .from('user_roles')
10 .select('roles(role_permissions(permissions(resource, action)))')
11 .eq('user_id', userId)
12 .eq('org_id', orgId)
13 .single()
14
15 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 }
22
23 return Array.from(permissions)
24 },
25 ['user-permissions'],
26 { revalidate: 300, tags: ['permissions'] }
27)
28
29export async function hasPermission(
30 userId: string,
31 orgId: string,
32 resource: string,
33 action: string
34) {
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').

5

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.

prompt.txt
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, Actions
5// - "Create Custom Role" Button opens Dialog with Form: Input for name, Textarea for description
6// - Click a role row to navigate to app/admin/roles/[id]/page.tsx showing the PermissionMatrix filtered to that role
7// - System roles (owner, admin) cannot be deleted — disable the Delete button
8// 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 Date
10// - Changing the Select calls a Server Action to update user_roles and revalidateTag('permissions')
11// - AlertDialog for confirming role changes on admin/owner roles
12// - "Invite Member" Button at top
13// 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

lib/permissions.ts
1import { createClient } from '@/lib/supabase/server'
2import { unstable_cache } from 'next/cache'
3
4export const getUserPermissions = unstable_cache(
5 async (userId: string, orgId: string) => {
6 const supabase = await createClient()
7
8 const { data } = await supabase
9 .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()
16
17 const permissions = new Set<string>()
18 const rolePerms =
19 (data as any)?.roles?.role_permissions ?? []
20
21 for (const rp of rolePerms) {
22 if (rp.permissions) {
23 permissions.add(
24 `${rp.permissions.resource}.${rp.permissions.action}`
25 )
26 }
27 }
28
29 return Array.from(permissions)
30 },
31 ['user-permissions'],
32 { revalidate: 300, tags: ['permissions'] }
33)
34
35export async function hasPermission(
36 userId: string,
37 orgId: string,
38 resource: string,
39 action: string
40): Promise<boolean> {
41 const perms = await getUserPermissions(userId, orgId)
42 return perms.includes(`${resource}.${action}`)
43}
44
45export async function requirePermission(
46 userId: string,
47 orgId: string,
48 resource: string,
49 action: string
50) {
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.

ChatGPT Prompt

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.

Build Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.