• ShipSaaSDocumentation
  • Introduction
  • Getting Started
    • Installation
    • Environment Setup
    • Database Setup
    • Development Guide
  • Database
    • Repository Pattern
    • Drizzle Queries
    • Migration Workflow
    • Drizzle Studio
    • Best Practices
  • File Management
    • File Upload Component
    • Server Actions
    • Storage Configuration
  • Authentication Methods
    • Credentials Authentication
    • Magic Link Authentication
    • Two-Factor Authentication (2FA)
    • Social Authentication
    • API Keys & Bearer Tokens
    • Organizations (Multi-tenant)
  • Authorization & Permissions
    • Roles & Hierarchy
    • Route Protection
    • API Protection
    • CASL Permissions
  • Email & Notifications
    • Resend Setup
    • React Email Templates
    • Notification System
    • Email Workflows
    • Customization & Extension
  • Stripe & Payments
    • Stripe Setup
    • Better Auth Integration
    • Checkout Methods
    • Subscription Management
    • Admin Dashboard
    • Webhook Events
  • Architecture & Development
    • Presentation Layer
    • Service Layer
    • Persistence Layer
    • Helpers Architecture
    • Project Structure
    • Development Workflow
  • Testing
    • General Testing
    • Unit Testing
    • UI Component Testing
    • Repository Testing
    • Service Testing
    • End-to-End Testing
  • Deployment
    • Vercel Deployment
    • GitHub Actions CI/CD
    • Database Backup & Restore
    • Database Migrations
    • Troubleshooting Deployment
  • AI Agents Integration
  • Blog Management
    • Blog Setup
    • MDX Blog
    • Database Blog
  • Async Tasks
    • Setup
    • Email Workflows
    • Payment Processing
    • Maintenance Jobs
  1. Documentation
  2. Authorization & Permissions
  3. Route Protection
Buydocschangelogsdemocommunity

Route Protection

PreviousRoles & HierarchyNextAPI Protection
On this page
Types of Route Protection🌍 Public Routes🔒 Private Routes👤 Role-Based Routes👑 Admin RouteswithAuth UsageRoute-Level Gating in the ProxyReading the Session Under Cache ComponentsError PagesForbidden Page (403)Practical ExamplesBasic Page ProtectionAdmin Page ProtectionUsing withAuthAdmin ShortcutTesting Route Protection

Route protection ensures only authorized users can access specific pages in your application. The boilerplate provides multiple layers of protection for different scenarios.

Perfect for: Protecting dashboards, admin panels, subscription features, and role-specific content.

Types of Route Protection

🌍 Public Routes

Accessible by everyone (including unauthenticated users)

tsx
// No protection needed
export default function PublicPage() {
  return <div>Anyone can see this</div>
}

Examples: Landing page, login, register, documentation

🔒 Private Routes

Requires authentication (any logged-in user)

tsx
// Using withAuth HOC
import { withAuth } from '@/components/features/auth/with-auth'

function DashboardPage() {
  return <div>Only logged-in users see this</div>
}

export default withAuth(DashboardPage)

Examples: Dashboard, account settings, user profile

👤 Role-Based Routes

Requires specific role or permission

tsx
import { withAuth } from '@/components/features/auth/with-auth'

function ModeratorPanel() {
  return <div>Only moderators and above</div>
}

export default withAuth(ModeratorPanel, {
  requiredRole: 'MODERATOR'
})

Examples: Content moderation, user management, analytics

👑 Admin Routes

Admin/backoffice access only

tsx
import { withAuth } from '@/components/features/auth/with-auth'

function AdminDashboard() {
  return <div>Admin backoffice</div>
}

export default withAuth(AdminDashboard, {
  requiredRole: 'ADMIN',
  redirectTo: '/unauthorized'
})

Shortcut available: The boilerplate also includes withAuthAdmin() as a convenient shortcut for admin-only pages. It automatically requires ADMIN role and redirects unauthorized users.

Examples: User management, system settings, analytics dashboard

withAuth Usage

The withAuth HOC has a simple signature based on the actual boilerplate code:

typescript
withAuth<P>(WrappedComponent: ComponentType<P & WithAuthProps>, requiredRole?: Roles)
typescript
// Default protection (USER role)
export default withAuth(MyPage)

// The component receives user props
function MyPage({ user }: WithAuthProps) {
  return <div>Hello {user.name}</div>
}
typescript
import { RoleConst } from '@/services/types/domain/auth-types'

// Require ADMIN role
export default withAuth(MyPage, RoleConst.ADMIN)

// Require MODERATOR role
export default withAuth(MyPage, RoleConst.MODERATOR)

// Available roles: USER, MODERATOR, REDACTOR, ADMIN, SUPER_ADMIN
typescript
import { withAuthAdmin } from '@/components/features/auth/with-auth'

// Convenient shortcut for admin-only pages
export default withAuthAdmin(MyPage)

// Equivalent to: withAuth(MyPage, RoleConst.ADMIN)

Route-Level Gating in the Proxy

Authenticated routes are gated in src/proxy.ts before anything renders. This matters under Cache Components: those routes stream, and a redirect() fired mid-render leaves after a 200 has already started, so it can no longer change the status code.

typescript
// src/proxy.ts
import { getSessionCookie } from 'better-auth/cookies'

const AUTHENTICATED_SEGMENTS = ['/account', '/admin', '/chat', '/dashboard', '/team']

export default function middleware(request: NextRequest) {
  const locale = localeOf(request.nextUrl.pathname)

  if (isAuthenticatedPath(request.nextUrl.pathname, locale) && !getSessionCookie(request)) {
    return NextResponse.redirect(new URL(`/${locale}/login`, request.url))
  }

  // ... i18n routing, theme cookie
}

getSessionCookie only checks that a cookie is present — it validates nothing, and anyone can forge a cookie. This is Better Auth's documented optimistic check: it saves a useless render, it is not a security boundary. Real enforcement stays server-side, in withAuth, in the DAL, and in the service-layer authorization.

Add your own protected segment to AUTHENTICATED_SEGMENTS when you create one.

Reading the Session Under Cache Components

Never await the session at the top level of a layout: it holds the whole segment, {children} included, behind the request, and the static shell is lost. The layout creates the promise and passes it down; consumers unwrap it behind a <Suspense> boundary.

tsx
// src/app/[locale]/(app)/layout.tsx
export default function AppLayout({ children }: { children: React.ReactNode }) {
  const userPromise = getCurrentUserDal() // created, never awaited

  return (
    <AuthProvider userPromise={userPromise}>
      <Suspense fallback={<SidebarSkeleton />}>
        <AppSidebar />
      </Suspense>
      {children}
    </AuthProvider>
  )
}

getCurrentUserDal() uses 'use cache: private', the only directive allowed to read cookies() and headers(). Its result is kept in the browser, never on the server. Client hooks (useAuth, useOrganization) resolve it with use(), so they suspend — call them only from a component behind a boundary.

Error Pages

Forbidden Page (403)

The boilerplate includes a forbidden page for users who don't have sufficient permissions:

tsx
// src/app/forbidden.tsx
export default function ForbiddenPage() {
  return (
    <div className="container mx-auto py-16 text-center">
      <h1 className="text-4xl font-bold mb-4">403 - Forbidden</h1>
      <p className="text-lg mb-8">You don't have permission to access this resource.</p>
      <Link href="/dashboard" className="btn btn-primary">
        Go to Dashboard
      </Link>
    </div>
  )
}

Practical Examples

Basic Page Protection

tsx
// src/app/dashboard/page.tsx
import withAuth, { WithAuthProps } from '@/components/features/auth/with-auth'
import { RoleConst } from '@/services/types/domain/auth-types'

function DashboardPage({ user }: WithAuthProps) {
  return (
    <div>
      <h1>Dashboard</h1>
      <p>Welcome {user.name}!</p>
    </div>
  )
}

// Requires USER role (default)
export default withAuth(DashboardPage)

Admin Page Protection

tsx
// src/app/admin/page.tsx
import withAuth, { WithAuthProps } from '@/components/features/auth/with-auth'
import { RoleConst } from '@/services/types/domain/auth-types'

function AdminPage({ user }: WithAuthProps) {
  return (
    <div>
      <h1>Admin Panel</h1>
      <p>Admin user: {user.name}</p>
    </div>
  )
}

// Requires ADMIN role
export default withAuth(AdminPage, RoleConst.ADMIN)

Using withAuthAdmin Shortcut

tsx
// src/app/admin/users/page.tsx
import { withAuthAdmin, WithAuthProps } from '@/components/features/auth/with-auth'

function UserManagementPage({ user }: WithAuthProps) {
  return (
    <div>
      <h1>User Management</h1>
      <p>Admin: {user.name}</p>
    </div>
  )
}

// Shortcut for ADMIN role requirement
export default withAuthAdmin(UserManagementPage)

Testing Route Protection

1

Test unauthenticated access

  1. Try accessing protected routes without login
  2. Verify correct redirect to login page
  3. Check unauthorized error messages
2

Test role-based access

  1. Create users with different roles
  2. Try accessing protected pages with each role
  3. Verify correct redirections and permissions
  4. Test role inheritance works correctly
3

Test middleware protection

  1. Test route-level middleware rules
  2. Verify correct redirects for unauthorized access
  3. Check performance impact of middleware

Pages secured! Your application routes are now protected with flexible, role-based access control that scales with your needs.