The role-based visibility system provides a unified API for controlling content access based on user roles. Supports both Supabase user roles (app-level) and Clerk organization roles (org-level) through a single, consistent interface.

Version: 1.0 | Status: ✅ Production Ready

Looking for page-level protection? See the dedicated Page-Level Protection Guide for complete coverage of requireRole() with hierarchical privilege escalation.

Looking for declarative page protection? See the Auth Layout Role Guards Guide for protecting entire pages using Auth layout props instead of manual role checks.

The role guard system provides:

  • Unified API for Supabase and Clerk roles
  • Server-side security with zero client JavaScript
  • Automatic caching with configurable TTL (default: 1 minute)
  • Type safety with full TypeScript support
  • Flexible usage - utilities and components
  • Well-tested - 54+ unit and component tests

🛡️ Utility Functions

Use canViewContent(), requireRole(), and getUserRole() for programmatic role checks with detailed results.

🎨 Wrapper Components

Use <RoleGuard> components in Astro and React to hide/show content based on user roles with zero configuration.

⚡ Performance

Built-in caching with 1-minute TTL reduces database queries. Configure cache duration per check.

🔍 Debug Mode

Development-only debug mode shows role checking details to help troubleshoot access issues.

src/pages/admin.astro
---
import RoleGuard from '#components/astro/RoleGuard.astro'
---
<RoleGuard allowedRoles={['admin', 'super_admin']}>
<h1>Admin Dashboard</h1>
<p>Only admins can see this content</p>
</RoleGuard>

That’s it! The component handles everything:

  • ✅ Fetches user role from Astro.locals or Supabase
  • ✅ Checks if user has any allowed role
  • ✅ Hides content if unauthorized
  • ✅ Caches role queries (1-minute TTL)

The astro-basics project uses two independent role systems:

Storage: Supabase users table Sync Source: Clerk webhook → Supabase Roles: member, admin, super_admin

// Determines app-wide privileges
type UserRole = 'member' | 'admin' | 'super_admin'

Use Cases:

  • Global admin features
  • User management
  • System configuration
  • Cross-organization permissions

Storage: Clerk session claims (Astro.locals.userRole) Roles: org:admin, org:member

// Determines organization-specific privileges
type OrgRole = 'org:admin' | 'org:member'

Use Cases:

  • Organization settings
  • Team management
  • Org-specific features
  • Multi-tenant access control

The role guard system works with both role systems through a unified API:

type AnyRole = UserRole | OrgRole
// Can be: 'member' | 'admin' | 'super_admin' | 'org:admin' | 'org:member'

The project offers three complementary patterns for role-based access control. Choose based on your specific needs:

PatternBest ForImplementationWhen to Use
Auth Layout PropsEntire page protection<Auth requiredRoles={[...]}/>Admin pages, role-specific sections
requireRole() HelperPage-level redirectionawait requireRole(...)API routes, forced redirects
RoleGuard ComponentsSection visibility<RoleGuard allowedRoles={[...]}/>Conditional content, mixed pages

Declarative page protection using layout props

---
import Auth from '#layouts/Auth.astro'
const requiredRoles: AnyRole[] = ['admin', 'super_admin']
---
<Auth pageTitle="Admin Dashboard" requiredRoles={requiredRoles}>
<!-- Entire page content protected -->
</Auth>
{
/* The Auth layout denies in place (shows an "insufficient permissions" notice
and withholds the page) — it cannot redirect. For a redirect, guard in the
page's own frontmatter with redirectUnauthorized(Astro, requiredRoles). */
}

Advantages:

  • ✅ Declarative and self-documenting
  • ✅ Automatic error handling
  • ✅ Performance optimized (parallel checks)
  • ✅ Custom redirect URLs

See: Auth Layout Role Guards Guide

Primary role-checking function - Returns boolean indicating if user can view content.

canViewContent(
locals: App.Locals,
allowedRoles: AnyRole[],
options?: Partial<RoleGuardConfig>
): Promise<boolean>
---
import { canViewContent } from '#utils/role-guard'
const canViewAdmin = await canViewContent(Astro.locals, ['admin', 'super_admin'])
---
{canViewAdmin && <AdminPanel />}

Return Value:

  • true - User has at least ONE of the allowed roles
  • false - User lacks all allowed roles OR not authenticated

Enhanced version - Returns detailed authorization result with debugging info.

canViewContentDetailed(
locals: App.Locals,
allowedRoles: AnyRole[],
options?: Partial<RoleGuardConfig>
): Promise<RoleCheckResult>
---
import { canViewContentDetailed } from '#utils/role-guard'
const result = await canViewContentDetailed(Astro.locals, ['admin'])
if (!result.allowed) {
console.log('Access denied:', result.reason)
console.log('User role:', result.userRole)
}
---

Page-level protection - Throws 403 error if user lacks required role.

requireRole(
locals: App.Locals,
allowedRoles: AnyRole[],
options?: Partial<RoleGuardConfig>
): Promise<void>
src/pages/super-admin/index.astro
---
import { requireRole } from '#utils/role-guard'
// Protect entire page - throws if unauthorized
await requireRole(Astro.locals, ['super_admin'])
---
<html>
<body>
<h1>Super Admin Dashboard</h1>
<!-- All content protected - only renders if check passes -->
</body>
</html>

Extracts user role from Astro.locals with optional Supabase fallback.

getUserRole(locals: App.Locals, fetchFromSupabase?: boolean): Promise<AnyRole | null>
---
import { getUserRole } from '#utils/role-guard'
// Get org role from Clerk session (if available)
const orgRole = await getUserRole(Astro.locals, false)
// Get user role from Supabase (with fallback)
const userRole = await getUserRole(Astro.locals, true)
console.log('Org role:', orgRole) // e.g., 'org:admin' or null
console.log('User role:', userRole) // e.g., 'admin' or 'member'
---

Fetch Priority:

  1. Astro.locals.userRole (Clerk org role from middleware)
  2. Supabase query (if fetchFromSupabase === true)
  3. null (if not authenticated)

Semantic aliases for role checking with explicit OR/AND logic.

hasAnyRole(locals: App.Locals, roles: AnyRole[]): Promise<boolean>
hasAllRoles(locals: App.Locals, roles: AnyRole[]): Promise<boolean>
---
import { hasAnyRole } from '#utils/role-guard'
// User needs to be admin OR super_admin
const isStaff = await hasAnyRole(Astro.locals, ['admin', 'super_admin'])
---
{isStaff && <StaffTools />}

Type guard - Validates if string is a valid role.

const userInput = 'admin'
if (isValidRole(userInput)) {
// TypeScript now knows userInput is AnyRole
const label = ROLE_LABELS[userInput]
}

Human-readable labels - Converts role identifiers to display names.

formatRoleForDisplay('super_admin') // Returns: "Super Admin"
formatRoleForDisplay('org:admin') // Returns: "Organization Admin"
formatRoleForDisplay('member') // Returns: "Member"

Server-side component for Astro pages - performs authorization server-side (zero client JavaScript).

interface Props {
allowedRoles: AnyRole[] // Required - roles that can view content
fallback?: string // Optional - message when access denied
debug?: boolean // Optional - show debug info (dev only)
fetchFromSupabase?: boolean // Optional - query Supabase (default: true)
}
---
import RoleGuard from '#components/astro/RoleGuard.astro'
---
<RoleGuard allowedRoles={['admin', 'super_admin']}>
<div class="admin-panel">
<h2>Admin Controls</h2>
<button>Delete All Users</button>
</div>
</RoleGuard>

Client-side component for React - requires server-fetched role data as props.

interface RoleGuardProps {
userRole: AnyRole | null // Required - pre-fetched server-side
allowedRoles: AnyRole[] // Required - roles that can view content
children: ReactNode // Required - content to protect
fallback?: ReactNode // Optional - shown when access denied
loading?: boolean // Optional - show loading state
className?: string // Optional - custom CSS class
'data-testid'?: string // Optional - for testing
}
src/pages/dashboard.astro
---
import Dashboard from '#components/react/Dashboard'
import { getUserRole } from '#utils/role-guard'
// Fetch role server-side
const userRole = await getUserRole(Astro.locals, true)
---
<Dashboard userRole={userRole} client:load />

Show different content based on role hierarchy.

---
import { canViewContent } from '#utils/role-guard'
const canViewBasic = await canViewContent(Astro.locals, ['member', 'admin', 'super_admin'])
const canViewPremium = await canViewContent(Astro.locals, ['admin', 'super_admin'])
const canViewAdmin = await canViewContent(Astro.locals, ['super_admin'])
---
{
canViewBasic && (
<section>
<h2>Basic Content</h2>
<p>All authenticated users can see this</p>
</section>
)
}
{
canViewPremium && (
<section>
<h2>Premium Content</h2>
<p>Only admins and super admins can see this</p>
</section>
)
}
{
canViewAdmin && (
<section>
<h2>Super Admin Content</h2>
<p>Highest privilege only</p>
</section>
)
}

Support both Supabase and Clerk roles in same page.

<RoleGuard
allowedRoles={[
'super_admin', // Supabase user role
'org:admin', // Clerk org role
]}
>
<AdvancedSettings />
</RoleGuard>

Combine page-level and component-level guards for granular control.

See Page-Level Protection Guide for more examples and security best practices.

---
import { requireRole } from '#utils/role-guard'
import RoleGuard from '#components/astro/RoleGuard.astro'
// Page-level: Only admins can access page at all
await requireRole(Astro.locals, ['admin', 'super_admin'])
---
<html>
<body>
<h1>Admin Dashboard</h1>
<!-- Component-level: Only super_admins see danger zone -->
<RoleGuard allowedRoles={['super_admin']}>
<DangerZone />
</RoleGuard>
</body>
</html>

Show/hide navigation items based on role.

---
import { canViewContent } from '#utils/role-guard'
const canViewAdmin = await canViewContent(Astro.locals, ['admin', 'super_admin'])
const canViewOrg = await canViewContent(Astro.locals, ['org:admin'])
---
<nav>
<a href="/">Home</a>
<a href="/dashboard">Dashboard</a>
{canViewOrg && <a href="/organization">Organization</a>}
{canViewAdmin && <a href="/dashboard/users">Users</a>}
</nav>

Hide sensitive form fields based on role.

function UserForm({ userRole }: Props) {
return (
<form>
<input name="username" placeholder="Username" />
<input name="email" type="email" placeholder="Email" />
{/* Only admins can change roles */}
<RoleGuard userRole={userRole} allowedRoles={['admin', 'super_admin']}>
<select name="role">
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
</RoleGuard>
</form>
)
}

Default cache TTL is 1 minute. Adjust based on your needs:

---
// Short TTL (30 seconds) for frequently changing roles
const canView = await canViewContent(Astro.locals, ['admin'], { cacheTTL: 30000 })
// Long TTL (5 minutes) for stable roles
const canView = await canViewContent(Astro.locals, ['admin'], { cacheTTL: 300000 })
---

Clerk org roles are in Astro.locals (no DB query). Use them when possible:

---
// ✅ Fast - uses Astro.locals (org role)
const canManageOrg = await canViewContent(Astro.locals, ['org:admin'], {
fetchFromSupabase: false,
})
// ⚠️ Slower - queries Supabase
const isAdmin = await canViewContent(Astro.locals, ['admin'], { fetchFromSupabase: true })
---

For protected routes, pre-fetch Supabase roles in middleware:

// src/middleware.ts (enhancement)
if (isProtectedRoute(context.request)) {
locals.supabaseRole = await fetchSupabaseRole(locals.userId)
}

Then role guards skip the DB query entirely.

When rendering lists, fetch role once and reuse:

---
import { getUserRole } from '#utils/role-guard'
const userRole = await getUserRole(Astro.locals, true)
const features = [
{ name: 'Basic Feature', allowedRoles: ['member', 'admin', 'super_admin'] },
{ name: 'Admin Feature', allowedRoles: ['admin', 'super_admin'] },
{ name: 'Super Feature', allowedRoles: ['super_admin'] },
]
---
{
features.map(
feature =>
userRole && feature.allowedRoles.includes(userRole) && <FeatureCard name={feature.name} />
)
}

Symptoms: Content hidden even though user has correct role

Causes:

  1. Supabase role not synced from Clerk
  2. Typo in role name
  3. Cache showing stale data

Solutions:

---
// Enable debug mode to see what's happening
---
<RoleGuard allowedRoles={['admin']} debug={true}>
<Content />
</RoleGuard>
<!-- Check output:
User Role: member (expected: admin)
Allowed Roles: admin
Access: Denied
-->

Symptoms: PGRST116 error or null role returned

Cause: Clerk user not yet synced to Supabase database

Solution: User creation happens automatically via webhook. The UserInfo.astro component handles this with upsert logic (see: src/components/astro/UserInfo.astro:55-64).

Symptoms: RoleGuard in React shows nothing

Cause: userRole prop not passed from server

Solution:

---
// WRONG - userRole not fetched
;<Dashboard client:load />
// RIGHT - fetch server-side and pass as prop
import { getUserRole } from '#utils/role-guard'
const userRole = await getUserRole(Astro.locals, true)
---
<Dashboard userRole={userRole} client:load />

Symptoms: Page loads slowly with many role checks

Solutions:

  1. Reduce cache TTL (if roles change frequently)
  2. Increase cache TTL (if roles are stable)
  3. Pre-fetch roles in middleware for protected routes
  4. Use Clerk org roles instead of Supabase roles when possible
// Monitor cache performance
import { getRoleCacheStats } from '#utils/role-guard'
const stats = getRoleCacheStats()
console.log('Cache entries:', stats.entries, 'Size:', stats.size)
---
const supabase = getSupabaseServiceRole()
const { data } = await supabase.from('users').select('role').eq('clerk_id', userId).single()
const isAdmin = data?.role === 'admin' || data?.role === 'super_admin'
---
{isAdmin && <AdminPanel />}

Benefits:

  • ✅ 80% less code
  • ✅ Type-safe
  • ✅ Automatic caching
  • ✅ Better error handling
  • ✅ Consistent with project patterns
  1. Never trust client-side - Always fetch roles server-side
  2. Defense in depth - Combine role guards with RLS policies and middleware
  3. Audit logging - Use canViewContentDetailed() for security audits
  4. Principle of least privilege - Grant minimum necessary permissions
  1. Component-level guards for granular UI control
  2. Page-level guards (requireRole) for route protection
  3. Middleware guards for API endpoint protection
  4. Consistent role naming - Use constants from #utils/role-types
  1. Mock Astro.locals in tests
  2. Test both authorized and unauthorized cases
  3. Verify fallback content renders
  4. Check accessibility attributes (role, aria-*)

The role-based visibility system provides:

🎯 Unified API

Single interface for both Supabase and Clerk roles

🔒 Server-Side Security

Zero client JavaScript, all checks happen server-side

⚡ Auto-Caching

Configurable cache TTL (default: 1 minute)

🛡️ Type Safety

Full TypeScript support with strict type checking

🧪 Well-Tested

54+ unit and component tests ensure reliability

👨‍💻 Developer-Friendly

Matches existing project patterns and conventions

Quick Reference:

  • Simple checks: canViewContent()
  • Page protection: requireRole()
  • Debugging: canViewContentDetailed()
  • Astro components: <RoleGuard>
  • React components: <RoleGuard userRole={...}>

For implementation details, see the role-based visibility system implementation plan.