# How to Build User permission management with V0

- Tool: v0
- Difficulty: Advanced
- Compatibility: V0 Premium or higher
- Last updated: April 2026

## 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.

## Before you start

- 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

## Step-by-step guide

### 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.

```
// Paste this prompt into V0's AI chat:
// Create a Supabase schema for role-based access control:
// 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)
// 2. permissions table: id (uuid PK), resource (text — 'projects', 'users', 'billing', 'settings'), action (text — 'create', 'read', 'update', 'delete'), description (text), UNIQUE(resource, action)
// 3. role_permissions junction: role_id (uuid FK), permission_id (uuid FK), PRIMARY KEY(role_id, permission_id)
// 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)
// 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.
// Seed 4 default roles (owner, admin, editor, viewer) with appropriate permissions.
// 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.

```
'use client'

import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import { togglePermission } from '@/app/actions/permissions'

type Permission = { id: string; resource: string; action: string }
type Role = { id: string; name: string; is_system: boolean }
type RolePermission = { role_id: string; permission_id: string }

export function PermissionMatrix({
  roles, permissions, rolePermissions,
}: {
  roles: Role[]
  permissions: Permission[]
  rolePermissions: RolePermission[]
}) {
  const hasPermission = (roleId: string, permId: string) =>
    rolePermissions.some((rp) => rp.role_id === roleId && rp.permission_id === permId)

  const grouped = permissions.reduce((acc, p) => {
    if (!acc[p.resource]) acc[p.resource] = []
    acc[p.resource].push(p)
    return acc
  }, {} as Record<string, Permission[]>)

  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>Permission</TableHead>
          {roles.map((role) => (
            <TableHead key={role.id} className="text-center">
              <Badge variant={role.is_system ? 'default' : 'secondary'}>{role.name}</Badge>
            </TableHead>
          ))}
        </TableRow>
      </TableHeader>
      <TableBody>
        {Object.entries(grouped).map(([resource, perms]) => (
          perms.map((perm) => (
            <TableRow key={perm.id}>
              <TableCell className="font-medium">
                {resource}.{perm.action}
              </TableCell>
              {roles.map((role) => (
                <TableCell key={role.id} className="text-center">
                  <Checkbox
                    checked={hasPermission(role.id, perm.id)}
                    onCheckedChange={() => togglePermission(role.id, perm.id)}
                    disabled={role.name === 'owner'}
                  />
                </TableCell>
              ))}
            </TableRow>
          ))
        ))}
      </TableBody>
    </Table>
  )
}
```

**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.

```
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

const routePermissions: Record<string, { resource: string; action: string }> = {
  '/admin/users': { resource: 'users', action: 'read' },
  '/admin/settings': { resource: 'settings', action: 'read' },
  '/admin/billing': { resource: 'billing', action: 'read' },
  '/admin/roles': { resource: 'users', action: 'update' },
}

export async function middleware(req: NextRequest) {
  const path = req.nextUrl.pathname
  const permission = Object.entries(routePermissions).find(([route]) =>
    path.startsWith(route)
  )?.[1]

  if (!permission) return NextResponse.next()

  const userId = req.cookies.get('user_id')?.value
  const orgId = req.cookies.get('org_id')?.value

  if (!userId || !orgId) {
    return NextResponse.redirect(new URL('/login', req.url))
  }

  const { data: hasAccess } = await supabase.rpc('check_permission', {
    p_user_id: userId,
    p_org_id: orgId,
    p_resource: permission.resource,
    p_action: permission.action,
  })

  if (!hasAccess) {
    return NextResponse.redirect(new URL('/unauthorized', req.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: '/admin/:path*',
}
```

**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.

```
import { createClient } from '@/lib/supabase/server'
import { unstable_cache } from 'next/cache'

export const getUserPermissions = unstable_cache(
  async (userId: string, orgId: string) => {
    const supabase = await createClient()

    const { data } = await supabase
      .from('user_roles')
      .select('roles(role_permissions(permissions(resource, action)))')
      .eq('user_id', userId)
      .eq('org_id', orgId)
      .single()

    const permissions = new Set<string>()
    const rolePerms = (data as any)?.roles?.role_permissions ?? []
    for (const rp of rolePerms) {
      if (rp.permissions) {
        permissions.add(`${rp.permissions.resource}.${rp.permissions.action}`)
      }
    }

    return Array.from(permissions)
  },
  ['user-permissions'],
  { revalidate: 300, tags: ['permissions'] }
)

export async function hasPermission(
  userId: string,
  orgId: string,
  resource: string,
  action: string
) {
  const perms = await getUserPermissions(userId, orgId)
  return perms.includes(`${resource}.${action}`)
}
```

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

```
// Paste this prompt into V0's AI chat:
// Build two admin pages for permission management:
// 1. Roles page at app/admin/roles/page.tsx:
//    - Table listing all roles with columns: Name (Badge), Description, Permissions Count, System Badge, Actions
//    - "Create Custom Role" Button opens Dialog with Form: Input for name, Textarea for description
//    - Click a role row to navigate to app/admin/roles/[id]/page.tsx showing the PermissionMatrix filtered to that role
//    - System roles (owner, admin) cannot be deleted — disable the Delete button
// 2. Members page at app/admin/members/page.tsx:
//    - Table with columns: Avatar, Name, Email, Role Select (dropdown of available roles), Assigned By, Assigned Date
//    - Changing the Select calls a Server Action to update user_roles and revalidateTag('permissions')
//    - AlertDialog for confirming role changes on admin/owner roles
//    - "Invite Member" Button at top
// 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 example

File: `lib/permissions.ts`

```typescript
import { createClient } from '@/lib/supabase/server'
import { unstable_cache } from 'next/cache'

export const getUserPermissions = unstable_cache(
  async (userId: string, orgId: string) => {
    const supabase = await createClient()

    const { data } = await supabase
      .from('user_roles')
      .select(
        'roles(role_permissions(permissions(resource, action)))'
      )
      .eq('user_id', userId)
      .eq('org_id', orgId)
      .single()

    const permissions = new Set<string>()
    const rolePerms =
      (data as any)?.roles?.role_permissions ?? []

    for (const rp of rolePerms) {
      if (rp.permissions) {
        permissions.add(
          `${rp.permissions.resource}.${rp.permissions.action}`
        )
      }
    }

    return Array.from(permissions)
  },
  ['user-permissions'],
  { revalidate: 300, tags: ['permissions'] }
)

export async function hasPermission(
  userId: string,
  orgId: string,
  resource: string,
  action: string
): Promise<boolean> {
  const perms = await getUserPermissions(userId, orgId)
  return perms.includes(`${resource}.${action}`)
}

export async function requirePermission(
  userId: string,
  orgId: string,
  resource: string,
  action: string
) {
  const allowed = await hasPermission(userId, orgId, resource, action)
  if (!allowed) {
    throw new Error(`Permission denied: ${resource}.${action}`)
  }
}
```

## Common mistakes

- **Checking permissions only in the frontend UI** — Hiding buttons in the UI is not security — users can call API routes directly, modify requests, or use browser dev tools to bypass frontend checks. Fix: Always enforce permissions server-side in middleware, API routes, and Server Actions. Frontend checks are for UX only (hiding irrelevant buttons).
- **Calling check_permission RPC on every single request without caching** — Database roundtrips add 10-50ms per request. With middleware checking permissions on every route, page loads become noticeably slower. Fix: Cache the user's permission set using unstable_cache with a 5-minute TTL. Invalidate with revalidateTag('permissions') when roles change.
- **Using NEXT_PUBLIC_ prefix for SUPABASE_SERVICE_ROLE_KEY** — The service role key bypasses all RLS policies. Exposing it in the browser lets anyone read, write, or delete any data. Fix: 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

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/user-permission-management
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/user-permission-management
