# How to Add Custom Roles to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

Custom roles need four pieces working together: a roles table defining what exists, a user_roles join table mapping users to roles, RLS policies on every data table enforcing those roles at the database level, and a React context exposing the current user's role to the UI. With Lovable or V0 you can ship a working three-role system (admin, editor, member) in 2–4 hours for $0–$25/month. The critical mistake is adding UI-level role guards without matching RLS policies — the database must enforce access, not just the frontend.

## What Custom Roles Actually Are

Custom roles are how you answer 'what can this user do?' after authentication has answered 'who is this user?'. A basic three-role system gives you admins who manage the app and its users, editors who create and modify content, and members who can only read. Roles live in your database and are enforced in two places: the UI (hide buttons, redirect pages) and the database via Supabase Row Level Security policies. Both are required — UI-only role checks are a security theater that any developer can bypass by calling the Supabase REST API directly. The product decisions are how many roles you need, whether a user can hold multiple roles simultaneously, and whether role changes take effect immediately or require re-login.

## Anatomy of the Feature

Six components make role-based access control work. The most important — and the one AI tools most often generate incompletely — is the RLS policy layer.

- **Roles and permissions tables** (data): A roles table defines available roles (id, name, description). A user_roles join table maps users to roles (user_id uuid references auth.users, role_id int references roles, assigned_by uuid, assigned_at timestamptz). For apps that need fine-grained permission control beyond role names, a permissions table (id, action, resource) and a role_permissions join table let you define exactly what each role can do.
- **Role check middleware** (backend): A server-side function that reads the user's role(s) from user_roles and compares against the required permission for the route or action. In Next.js App Router, middleware.ts reads the role from the JWT custom claim (set via Supabase Auth hook) or queries user_roles directly. In Lovable (React Router), an AuthGuard component reads from the role context.
- **Admin panel — user management page** (ui): A table of all users with their current role and a dropdown to change it. The role update must go through a Supabase Edge Function using the service role key — not the anon key — because only privileged server-side code should be able to UPDATE user_roles. The admin panel page itself must redirect non-admins to /access-denied before rendering.
- **Protected route guard** (ui): A React component that reads the current user's role from context and either renders its children or returns an Access Denied screen. For admin-only routes (/admin/*), the guard checks role === 'admin'. For editor routes, it checks role === 'admin' || role === 'editor'. The guard must also handle the loading state while the role is being fetched from Supabase.
- **Role context provider** (ui): A React context that wraps the app and stores the current user's role and a hasPermission(action, resource) helper. Loaded once on auth state change (onAuthStateChange fires SIGNED_IN) by querying user_roles for the current user's role. Exposes role to all components without prop drilling.
- **Supabase custom JWT claims** (backend): A Supabase Auth hook (trigger on auth.users that fires on each token refresh) can embed the user's role into the JWT access token as a custom claim. RLS policies can then use auth.jwt() ->> 'role' to check the role without an extra DB lookup on every request. Set up in Supabase Dashboard → Authentication → Hooks.

## Data model

Three tables handle the full RBAC pattern. Run in the Supabase SQL editor (Dashboard → SQL Editor → New Query):

```sql
create table public.roles (
  id serial primary key,
  name text unique not null,
  description text
);

-- Seed default roles
insert into public.roles (name, description) values
  ('admin', 'Full access: manage users, content, and settings'),
  ('editor', 'Create and edit content; cannot manage users'),
  ('member', 'Read-only access to content');

create table public.user_roles (
  user_id uuid references auth.users(id) on delete cascade not null,
  role_id int references public.roles(id) on delete restrict not null,
  assigned_by uuid references auth.users(id),
  assigned_at timestamptz not null default now(),
  primary key (user_id, role_id)
);

alter table public.user_roles enable row level security;

create policy "Users can view own roles"
  on public.user_roles for select
  using (auth.uid() = user_id);

-- Admin role assignment: only service role key (Edge Function) can INSERT/DELETE
-- No anon INSERT/DELETE policy — intentional

create index user_roles_user_idx on public.user_roles (user_id);

-- Helper function: get current user role name
create or replace function public.get_my_role()
returns text as $$
  select r.name
  from public.user_roles ur
  join public.roles r on r.id = ur.role_id
  where ur.user_id = auth.uid()
  limit 1;
$$ language sql security definer stable;

-- Example: RLS on a content table using role check
-- (Adapt this pattern for every protected table in your app)
create table public.posts (
  id uuid primary key default gen_random_uuid(),
  title text not null,
  body text,
  author_id uuid references auth.users(id) not null,
  created_at timestamptz not null default now()
);

alter table public.posts enable row level security;

create policy "Members and above can read posts"
  on public.posts for select
  using (
    public.get_my_role() in ('admin', 'editor', 'member')
  );

create policy "Editors and admins can insert posts"
  on public.posts for insert
  with check (
    public.get_my_role() in ('admin', 'editor')
  );

create policy "Admins can delete posts"
  on public.posts for delete
  using (
    public.get_my_role() = 'admin'
  );
```

The get_my_role() function is security definer (runs with DB owner privileges) so it can query user_roles without the caller needing direct table access. Apply the same RLS pattern to every data table in your app — the UI guard and the DB policy must match.

## Build paths

### Lovable — fit 4/10, 2–3 hours

Lovable generates the roles schema, RLS policies, admin user list, and role context well with a detailed prompt. Best first choice if you are already using Lovable for the rest of the app.

1. Ensure Lovable Cloud (Supabase) is connected — go to Cloud tab and verify Database is provisioned
2. Paste the prompt below in Agent Mode; Lovable will generate the schema, Edge Functions, admin page, and React context
3. After generation, open Cloud tab → Database → SQL Editor and verify the roles, user_roles, and posts tables exist with RLS enabled
4. Navigate to /admin in the published app, log in as an admin, and verify the role dropdown updates the user_roles table
5. Log in as a member and attempt to navigate to /admin — verify the Access Denied page appears

Starter prompt:

```
Add role-based access control to this app using Supabase. Create three tables: 1) roles (id serial, name text unique, description text) seeded with admin, editor, member rows. 2) user_roles (user_id uuid references auth.users, role_id int references roles, assigned_by uuid, assigned_at timestamptz, PRIMARY KEY (user_id, role_id)). RLS on user_roles: users can SELECT their own rows; only service role (Edge Function) can INSERT or DELETE. 3) A get_my_role() SQL function that returns the current user's role name (security definer). Add RLS policies on the posts table using get_my_role(): admins can do anything; editors can SELECT/INSERT/UPDATE their own; members can only SELECT. Create a React RoleContext that: loads the current user's role on SIGNED_IN auth state change by calling get_my_role(); exposes role and hasPermission(requiredRole) to all components; calls supabase.auth.refreshSession() when the role changes. Create /admin/users page: shows a table of all users (email, role badge, last sign-in); role dropdown per row that calls a Supabase Edge Function update-user-role with service role key; page redirects non-admins to /access-denied. Create /access-denied page with a clear message and a back link. Add a role badge to the user profile header showing current role. Role constants must live in src/config/roles.ts (not hardcoded strings in 12 files).
```

Limitations:

- Role context updates after an admin changes another user's role only take effect for that user after their next token refresh — call supabase.auth.refreshSession() on the changed user's next page load
- Lovable sometimes generates client-side role checks without matching RLS policies — always verify in Supabase Dashboard that every policy exists before testing security
- Complex permission matrices (more than 5 roles, per-resource permissions) cause looping in Lovable; V0 or custom dev is better for those cases

### V0 — fit 4/10, 2–4 hours

V0 generates polished Next.js middleware for route protection and a clean admin dashboard with good TypeScript types. Best if you need an admin panel that looks production-quality immediately.

1. Prompt V0 with the spec below to generate the middleware, admin page, and role context
2. Open the Vars panel and add NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, and SUPABASE_SERVICE_KEY
3. Run the RBAC SQL schema (from the Data Model above) in your Supabase SQL editor
4. Deploy to Vercel — middleware.ts role checks require a deployed server environment to read cookies
5. Test role protection by logging in with a member account and attempting to navigate to /admin

Starter prompt:

```
Build role-based access control for a Next.js App Router app with Supabase. Roles: admin, editor, member. Components needed: 1) middleware.ts — for /admin/* routes, check the current user's role via get_my_role() Supabase RPC; redirect to /access-denied if role is not admin; for /editor/* routes, redirect members to /access-denied. 2) RoleProvider context (app/providers/role-provider.tsx) — client component that wraps the app; on mount, calls supabase.rpc('get_my_role') to load the current role; exposes role and hasPermission(requiredRole: 'admin' | 'editor' | 'member') function; refreshes after role changes. 3) app/admin/users/page.tsx — Server Component listing all users from auth.users (via service role client); each row shows email, current role (from user_roles join), and a RoleSelector client component with admin/editor/member dropdown. 4) app/api/admin/update-role/route.ts — POST handler using service role key to UPDATE user_roles; validates that requestor has admin role; returns error if self-demotion attempted. 5) app/access-denied/page.tsx — clear message with back link. Role constants enum in lib/roles.ts. State 'use Supabase Auth and Supabase RLS, no Clerk'.
```

Limitations:

- V0 sometimes defaults to Clerk for roles when the backend is not specified — explicitly state 'use Supabase Auth' in the prompt
- V0 does not auto-provision the database schema — run the SQL manually in Supabase
- middleware.ts role checks require database calls on every request to protected routes, which adds 20–50ms latency; mitigate by embedding the role in the JWT claim

### Custom — fit 5/10, 1 week

Full control over hierarchical roles, wildcard permissions, time-limited grants, and per-resource access control. Required when you need more than five roles or dynamic permission management.

1. Design the full roles and permissions schema: roles, permissions, role_permissions, user_roles tables with a hierarchical inheritance model if needed
2. Implement a get_effective_permissions(user_id) function that resolves inherited permissions through the role hierarchy
3. Embed the resolved permissions in the Supabase JWT as a custom claim array via Auth hook — avoids per-request DB permission lookups
4. Build a permissions management UI where admins can add and remove specific permissions from roles without code changes
5. Synchronize RLS policies with the permissions table using a code generation step or dynamic policy evaluation

Limitations:

- Hierarchical role inheritance (admin inherits all editor permissions) adds significant schema complexity that must be carefully tested
- Dynamic RLS policies based on a permissions table require either generated SQL or a security definer function that is called on every row access — adds query overhead

## Gotchas

- **Role check in React but no RLS policy in the database** — This is the most common RBAC security failure. AI generates a React route guard that hides the button or redirects non-admins, but forgets to add matching Supabase RLS policies on the data tables. Any developer with the Supabase anon key (visible in the browser's network tab) can call the Supabase REST API directly, skip the UI entirely, and read or modify data their role should not allow. Fix: For every role check in the UI, there must be a corresponding RLS policy on the database table. Test by calling the Supabase REST API directly as a member-role user (use the browser DevTools Network tab to grab the auth token) and confirming admin-only data returns an empty result or permission error.
- **Role update doesn't reflect until user re-logs in** — Supabase JWT access tokens are issued at login and cache the user's identity for up to 1 hour. If an admin changes another user's role in the database, that user's JWT still carries their old role. Any role check based on the JWT claim (auth.jwt() ->> 'role') continues to use the stale role, so the role change appears to have no immediate effect. Fix: Call supabase.auth.refreshSession() in the role context provider after a role change is detected (via a Realtime subscription on user_roles) to force a new JWT with the updated claim. Alternatively, use a direct user_roles DB query (not a JWT claim) for role checks so the DB is always the source of truth, at the cost of one extra query per check.
- **Admin accidentally removes their own admin role** — The admin user list page typically shows the currently logged-in admin in the same table as everyone else, with the same role dropdown. There is no default protection preventing an admin from selecting 'member' from their own row's dropdown and immediately losing access to the admin panel they are currently viewing. Fix: In the role update Edge Function, check if user_id === assigned_by and role_id corresponds to a demotion from admin — return a 403 with message 'You cannot change your own role'. In the UI, disable the role dropdown for the row matching the current user's ID and show a tooltip: 'Contact another admin to change your role'.
- **Lovable generates role names as hardcoded strings in 12 different files** — When prompted to add role checks throughout the app, Lovable writes if (role === 'admin') in each component, page, and Edge Function individually. If you later rename a role or add a new one, you must find and update every occurrence — usually missing at least one, which silently breaks that access path. Fix: Prompt Lovable: 'Create a roles constants file at src/config/roles.ts that exports ROLES = { ADMIN: 'admin', EDITOR: 'editor', MEMBER: 'member' } and import ROLES from this file everywhere a role check appears — no inline strings.' Then verify the generated code uses the constants import rather than inline strings.
- **V0 generates Clerk RBAC when Supabase was intended** — V0 is trained on many auth patterns and defaults to Clerk's organizationMembership() and role() APIs when a prompt mentions 'roles' without specifying the auth backend. If your project uses Supabase Auth, the Clerk code is incompatible and breaks your existing login flow. Fix: Always specify the backend explicitly at the start of the prompt: 'Use Supabase Auth and Supabase RLS for authentication and role-based access control — do not use Clerk.' Include this in every role-related prompt, not just the first one.

## Best practices

- Always enforce roles at the database level with RLS policies — UI guards are for UX, not security
- Store role names in a constants file (src/config/roles.ts) and import them everywhere — never write inline role strings that become stale when roles change
- Use a service role key in an Edge Function for all role assignment operations — never allow the anon key to modify user_roles
- Protect against self-demotion in the role assignment API: an admin should never be able to downgrade their own role from the same UI that manages others
- Refresh the user's JWT after a role change via supabase.auth.refreshSession() to avoid a 1-hour window where the old role still appears valid
- Index user_roles on user_id — at 10,000+ users, un-indexed role lookups in RLS policies add measurable query latency
- Test role enforcement by calling the Supabase REST API directly with a member token — if member data returns admin-only rows, you have a missing RLS policy
- Seed the three default roles at migration time so the roles table always has known IDs — hardcoding role IDs in RLS policies breaks if IDs differ between environments

## Frequently asked questions

### What's the difference between roles and permissions?

A role is a named group (admin, editor, member). A permission is a specific allowed action (create:post, delete:user). In a simple RBAC system, roles imply permissions — being an editor means you can create posts without needing to check an explicit 'create:post' permission. In more complex systems, you have a role_permissions join table that maps roles to specific permissions, giving you fine-grained control over what each role can do.

### Can a user have multiple roles at the same time?

With the user_roles schema above, yes — the PRIMARY KEY is (user_id, role_id), so a user can have multiple roles simultaneously. For most SaaS apps, one role per user is simpler and sufficient. Multi-role setups are useful in team tools where someone might be both an admin for their organization and a member of a specific project.

### How do I restrict a page so only admins can see it?

Two steps: 1) Add a route guard in your UI — in Next.js middleware.ts, check the user's role before the page renders and redirect to /access-denied if not admin. 2) Add an RLS policy on every Supabase table that page reads from, using get_my_role() = 'admin' in the USING clause. Step 1 handles the UX; step 2 handles the security. Both are required.

### Where should role checks live — in the UI or in the database?

Both. Database RLS policies are the security layer — they prevent unauthorized data access regardless of what the UI does. UI role checks are the UX layer — they hide irrelevant UI, redirect unauthorized routes, and make the app feel consistent. If you can only implement one, implement RLS policies first: a user who sees an admin button they can't use is annoying; a user who can read admin data without admin access is a breach.

### Can I add roles without writing any SQL?

Not with Supabase — you need to create the roles and user_roles tables and write at least basic RLS policies. However, the SQL in the Data Model section above is a complete, copy-paste schema you can run directly in the Supabase SQL editor without any modifications for a standard three-role setup. Clerk offers a no-SQL roles management UI if you prefer, at $25/mo for the Pro plan.

### How do I show different menus for different user types?

Load the current user's role into a React context on sign-in, then conditionally render menu items based on the role. For example: only show the 'Admin' menu item if role === 'admin'. Use the hasPermission(requiredRole) helper from the context rather than inline role comparisons. The menu hiding is a UX convenience — the actual protection is the route guard and RLS policies on the data behind those menu items.

### What happens if a user's role is changed while they're logged in?

Their active JWT still carries the old role for up to 1 hour (the default Supabase token lifetime). During that window, JWT-based checks see the old role; DB queries using get_my_role() see the new role immediately. To sync the JWT, call supabase.auth.refreshSession() after the role change — this fetches a new access token with the updated claim. For Supabase with Realtime, you can subscribe to changes on the user_roles table and trigger a refresh automatically.

### How many roles should a typical SaaS app have?

Three is the sweet spot for most apps: admin (full access), editor or manager (content/record management without user management), and member or viewer (read-only). Adding more roles increases complexity — each new role needs corresponding RLS policies on every table. Start with three, ship, and add roles only when a specific user type is consistently blocked from what they need or has access to more than they should.

---

Source: https://www.rapidevelopers.com/app-features/custom-roles
© RapidDev — https://www.rapidevelopers.com/app-features/custom-roles
