Documentation
Documentation
Introduction

Getting Started

Getting StartedInstallationQuick StartProject Structure

Architecture

Architecture OverviewTech StacktRPC MiddlewareDesign Principles

Patterns

Code Patterns & ConventionsFeature ModulesError HandlingType Safety

Database

DatabaseSchema DefinitionDatabase OperationsMigrationsCaching

API

tRPCProceduresRouterstRPC Proxy Setup
APIsOpenAPIREST Endpoints

Auth & Access

AuthenticationConfigurationOAuth ProvidersRolesSession Management
AuthorizationUser RolesPermissions

Routing & i18n

RoutingDeclarative RoutingNavigation
InternationalizationTranslationsLocale Routing

Components & UI

ComponentsButtonsFormsNavigationDialogs
StylesTailwind CSSThemingTypography

Storage

StorageConfigurationUsageBuckets

Configuration

ConfigurationEnvironment VariablesFeature Flags

Templates

Template GuidesCreate New FeatureCreate New PageCreate Database TableCreate tRPC RouterAdd Translations

Development

DevelopmentCommandsAI AgentsBest Practices

User Roles

System-wide role-based access control

Overview

User roles provide system-wide authorization that determines what actions users can perform across the entire application. Unlike organization permissions (which are scoped to specific organizations), roles apply globally.

UserRole Enum

Roles are defined in src/db/enums.ts:

export enum UserRole {
  ADMIN = "admin",
  USER = "user"
}

Role Definitions

RoleDescriptionAccess Level
ADMINSystem administratorsFull access to all features and admin panel
USERStandard usersAccess to own resources and organizations

You can extend this enum to add custom roles like MODERATOR, PREMIUM, or SUPPORT.

Server-Side Role Checks

Layout Guards

Use requireRole() in Server Components to protect entire routes:

// app/[locale]/admin/layout.tsx
import { requireRole } from "@/lib/auth/layout-guards";
import { UserRole } from "@/db/enums";

export default async function AdminLayout({
  children
}: {
  children: React.ReactNode
}) {
  // Redirects if not authenticated or not an admin
  const auth = await requireRole(UserRole.ADMIN);
  
  return (
    <div>
      <h1>Welcome, Admin {auth.user.email}</h1>
      {children}
    </div>
  );
}

Multiple Roles

Allow access to users with any of the specified roles:

// Allow both admins and premium users
const auth = await requireRole([UserRole.ADMIN, UserRole.PREMIUM]);

With Onboarding Check

Ensure user has completed onboarding:

const auth = await requireRole(UserRole.USER, {
  requireOnboardingCompleted: true,
  preserveCallback: true // Redirect back after onboarding
});

Redirect Behavior

requireRole() automatically redirects based on the situation:

ScenarioRedirect
Not authenticatedLogin page with callback
Wrong roleDefault page for user's actual role
Onboarding incompleteOnboarding page with callback
// Implementation from src/lib/auth/layout-guards.ts
export async function requireRole(
  allowedRoles: UserRole | UserRole[],
  options?: { 
    preserveCallback?: boolean;
    requireOnboardingCompleted?: boolean;
  }
): Promise<NonNullable<AuthRetrieval>>

tRPC Role-Based Procedures

Role Procedure

Create tRPC procedures that require specific roles:

import { createTRPCRouter, roleProcedure } from "@/trpc/procedures/trpc";
import { UserRole } from "@/db/enums";
import { z } from "zod";

export const adminRouter = createTRPCRouter({
  // Only admins can list all users
  listAllUsers: roleProcedure(UserRole.ADMIN)
    .query(async ({ ctx }) => {
      // ctx.user is guaranteed to exist and be an admin
      return await ctx.db.users.listDocuments({});
    }),
    
  // Allow multiple roles
  moderateContent: roleProcedure([UserRole.ADMIN, UserRole.MODERATOR])
    .input(z.object({ contentId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      // Either admin or moderator can execute this
      return await ctx.db.content.moderate(input.contentId);
    }),
});

Auth Procedure

For procedures that require any authenticated user (regardless of role):

import { authProcedure } from "@/trpc/procedures/trpc";

// Any authenticated user can access
getUserProfile: authProcedure
  .query(async ({ ctx }) => {
    // ctx.user and ctx.session are guaranteed to exist
    return await ctx.db.users.getDocument({ id: ctx.user.id });
  })

Public Procedure

For procedures that don't require authentication:

import { publicProcedure } from "@/trpc/procedures/trpc";

// Anyone can access
listPublicPosts: publicProcedure
  .query(async ({ ctx }) => {
    // ctx.user might be null
    return await ctx.db.posts.listPublicPosts();
  })

Client-Side Role Checks

Using useAuth Hook

"use client";

import { useAuth } from "@/lib/auth/client";
import { UserRole } from "@/db/enums";

export function UserDashboard() {
  const { user, hasRole, isAuthenticated } = useAuth();
  
  if (!isAuthenticated) {
    return <LoginPrompt />;
  }
  
  return (
    <div>
      <h1>Dashboard</h1>
      
      {/* Show admin panel only to admins */}
      {hasRole(UserRole.ADMIN) && (
        <AdminPanel />
      )}
      
      {/* Show different content based on role */}
      {user.role === UserRole.ADMIN ? (
        <AdminStats />
      ) : (
        <UserStats />
      )}
    </div>
  );
}

Conditional Rendering

import { useAuth } from "@/lib/auth/client";
import { UserRole } from "@/db/enums";

export function Navigation() {
  const { hasRole } = useAuth();
  
  return (
    <nav>
      <Link href="/dashboard">Dashboard</Link>
      
      {/* Admin-only link */}
      {hasRole(UserRole.ADMIN) && (
        <Link href="/admin">Admin Panel</Link>
      )}
    </nav>
  );
}

Client-Side Checks Are For UX Only - Always enforce authorization server-side. Client checks only hide UI elements but don't prevent API access.

Implementation Details

Role Procedure Implementation

From src/trpc/procedures/trpc.ts:

export const roleProcedure = (role: UserRole | UserRole[]) =>
  authProcedure.use(async ({ ctx, next, meta, input }) => {
    const allowedRoles = Array.isArray(role) ? role : [role];

    if (!ctx.user?.role || !allowedRoles.includes(ctx.user.role as UserRole)) {
      throw new TRPCError({
        code: "FORBIDDEN",
        message: ctx.t("statusCodes.403")
      });
    }

    return next({ ctx, input });
  });

Better-Auth Admin Plugin

Roles are integrated with Better-Auth's admin plugin:

// src/lib/auth/server.ts
import { admin } from "better-auth/plugins";

adminPlugin({
  adminRoles: [UserRole.ADMIN],
  defaultRole: UserRole.USER,
  impersonationSessionDuration: 60 * 60 * 24, // 1 day
})

Best Practices

1. Use Enums, Not Strings

await requireRole(UserRole.ADMIN);
await requireRole("admin"); // Type-unsafe

2. Protect Routes at Layout Level

// app/[locale]/admin/layout.tsx
// Protects all pages under /admin
await requireRole(UserRole.ADMIN);

3. Use Specific Procedures

Choose the right procedure for your use case:

// ✅ Specific role required
deleteUser: roleProcedure(UserRole.ADMIN)

// ✅ Any authenticated user
updateProfile: authProcedure

// ✅ Public access
getPublicStats: publicProcedure

4. Check Roles Early

export default async function Page() {
  // Check at the top of the component
  const auth = await requireRole(UserRole.ADMIN);
  
  // Now safely use auth.user
  const data = await fetchAdminData(auth.user.id);
  
  return <AdminView data={data} />;
}

5. Provide Clear Error Messages

if (!hasRole(UserRole.ADMIN)) {
  return (
    <Alert variant="destructive">
      <AlertTitle>Access Denied</AlertTitle>
      <AlertDescription>
        You need administrator privileges to access this page.
      </AlertDescription>
    </Alert>
  );
}

Adding Custom Roles

1. Update UserRole Enum

// src/db/enums.ts
export enum UserRole {
  ADMIN = "admin",
  MODERATOR = "moderator", // New role
  PREMIUM = "premium",     // New role
  USER = "user"
}

2. Update Better-Auth Config

// src/lib/auth/server.ts
adminPlugin({
  adminRoles: [UserRole.ADMIN, UserRole.MODERATOR],
  defaultRole: UserRole.USER,
})

3. Update Database Schema

Run migration to add the new role to the database:

npm run db:push

4. Use in Your Application

// Moderators can manage content
manageContent: roleProcedure([UserRole.ADMIN, UserRole.MODERATOR])
  .mutation(async ({ ctx, input }) => {
    // Implementation
  })

Examples

Admin-Only Page

// app/[locale]/admin/users/page.tsx
import { requireRole } from "@/lib/auth/layout-guards";
import { UserRole } from "@/db/enums";

export default async function AdminUsersPage() {
  await requireRole(UserRole.ADMIN);
  
  return <UserManagement />;
}

Role-Based tRPC Router

// src/trpc/procedures/routers/admin.ts
import { createTRPCRouter, roleProcedure } from "@/trpc/procedures/trpc";
import { UserRole } from "@/db/enums";
import { z } from "zod";

export const adminRouter = createTRPCRouter({
  listUsers: roleProcedure(UserRole.ADMIN)
    .input(z.object({
      page: z.number().default(1),
      limit: z.number().default(50),
    }))
    .query(async ({ ctx, input }) => {
      return await ctx.db.users.listDocuments({
        limit: input.limit,
        offset: (input.page - 1) * input.limit,
      });
    }),
    
  deleteUser: roleProcedure(UserRole.ADMIN)
    .input(z.object({ userId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      await ctx.db.users.deleteDocument({ id: input.userId });
      
      return {
        success: true,
        message: ctx.t("admin.userDeleted"),
      };
    }),
});

Conditional UI Component

"use client";

import { useAuth } from "@/lib/auth/client";
import { UserRole } from "@/db/enums";
import { Button } from "@/components/ui/button";

export function ContentActions({ contentId }: { contentId: string }) {
  const { user, hasRole } = useAuth();
  
  return (
    <div className="flex gap-2">
      {/* All users can edit their own content */}
      <Button>Edit</Button>
      
      {/* Only admins can delete */}
      {hasRole(UserRole.ADMIN) && (
        <Button variant="destructive">Delete</Button>
      )}
    </div>
  );
}

See Also

  • Authentication - User login and session management
  • Permissions - Organization-level permissions
  • tRPC Procedures - API endpoint authorization

On this page

Overview
UserRole Enum
Role Definitions
Server-Side Role Checks
Layout Guards
Multiple Roles
With Onboarding Check
Redirect Behavior
tRPC Role-Based Procedures
Role Procedure
Auth Procedure
Public Procedure
Client-Side Role Checks
Using useAuth Hook
Conditional Rendering
Implementation Details
Role Procedure Implementation
Better-Auth Admin Plugin
Best Practices
1. Use Enums, Not Strings
2. Protect Routes at Layout Level
3. Use Specific Procedures
4. Check Roles Early
5. Provide Clear Error Messages
Adding Custom Roles
1. Update UserRole Enum
2. Update Better-Auth Config
3. Update Database Schema
4. Use in Your Application
Examples
Admin-Only Page
Role-Based tRPC Router
Conditional UI Component
See Also