Skip to main content

Overview

The Hub frontend uses Next.js 16 App Router with Auth0 for authentication. The application implements role-based access control (RBAC) with three user roles: Player, Venue Owner, and Admin.

App Router Fundamentals

File-Based Routing

Next.js App Router uses file system conventions:

page.tsx

Creates a route endpoint

layout.tsx

Wraps pages with shared UI

[param]

Dynamic route segment

[...slug]

Catch-all route segment

Route Organization

app/
├── page.tsx                    → /
├── layout.tsx                  → Root layout
├── dashboard/
│   ├── page.tsx               → /dashboard
│   ├── layout.tsx             → Dashboard layout
│   ├── profile/
│   │   └── page.tsx           → /dashboard/profile
│   └── settings/
│       └── page.tsx           → /dashboard/settings
├── match/
│   ├── search/
│   │   └── page.tsx           → /match/search
│   ├── create/
│   │   └── page.tsx           → /match/create
│   ├── [id]/
│   │   └── page.tsx           → /match/:id
│   └── join/
│       └── [token]/
│           └── page.tsx       → /match/join/:token
├── venue/
│   └── [id]/
│       ├── page.tsx           → /venue/:id
│       └── resource/
│           └── [resourceId]/
│               └── page.tsx   → /venue/:id/resource/:resourceId
├── owner/
│   ├── layout.tsx             → Owner layout
│   ├── dashboard/
│   │   └── page.tsx           → /owner/dashboard
│   └── venues/
│       └── page.tsx           → /owner/venues
├── admin/
│   ├── layout.tsx             → Admin layout (with auth check)
│   ├── dashboard/
│   │   └── page.tsx           → /admin/dashboard
│   └── users/
│       └── page.tsx           → /admin/users
└── api/
    └── proxy/
        └── [...path]/
            └── route.ts       → /api/proxy/*

Authentication with Auth0

Auth0 Configuration

import {Auth0Client} from '@auth0/nextjs-auth0/server'

export const auth0 = new Auth0Client({
  domain: process.env.AUTH0_DOMAIN!,
  clientId: process.env.AUTH0_CLIENT_ID!,
  clientSecret: process.env.AUTH0_CLIENT_SECRET!,
  appBaseUrl: process.env.AUTH0_BASE_URL!,
  secret: process.env.AUTH0_SECRET!,
  authorizationParameters: {
    audience: process.env.AUTH0_AUDIENCE,
    scope: 'openid profile email offline_access'
  }
})

Session Management

Auth0 manages sessions server-side:
import {auth0} from '@/lib/auth0'

// Get current session
const session = await auth0.getSession()

// Check if authenticated
if (!session) {
  redirect('/login')
}

// Access token for API calls
const accessToken = session.tokenSet?.accessToken

Protected Routes

Layout-Level Protection

Implement authentication checks in layout components:
app/admin/layout.tsx
import {AdminSidebar} from '@/components/admin/admin-sidebar'
import {UserProfile} from '@/types'
import {auth0} from '@/lib/auth0'
import {redirect} from 'next/navigation'
import {apiFetch} from '@/lib/api'

export default async function AdminLayout({
  children
}: {
  children: React.ReactNode
}) {
  // Check authentication
  const session = await auth0.getSession()
  if (!session) redirect('/')
  
  // Check authorization
  const profile = await apiFetch<UserProfile>('/api/me')
  if (profile.role !== 'ADMIN') redirect('/dashboard')
  
  return (
    <div className="flex min-h-screen bg-background">
      <AdminSidebar user={profile} />
      <main className="flex-1 overflow-y-auto">
        {children}
      </main>
    </div>
  )
}
All routes under /admin/* are protected by this layout.
Layout-level protection is more efficient as it applies to all child routes and runs once per navigation.

Role-Based Access Control

User Roles

The application supports three roles:
types/index.ts
type UserRole = 'PLAYER' | 'OWNER' | 'ADMIN'

interface UserProfile {
  id: string
  email: string
  name: string
  role: UserRole
  city?: string
  skillLevel?: string
}

Route Protection by Role

Player (Default)

  • /dashboard/*
  • /match/*
  • /venue/*
  • /onboarding

Venue Owner

  • /owner/*
  • All Player routes

Admin

  • /admin/*
  • All routes

Authorization Logic

// Redirect based on role
function getDefaultRoute(role: UserRole): string {
  switch (role) {
    case 'ADMIN':
      return '/admin/dashboard'
    case 'OWNER':
      return '/owner/dashboard'
    case 'PLAYER':
    default:
      return '/dashboard'
  }
}

// Check role access
function hasAccess(userRole: UserRole, requiredRole: UserRole): boolean {
  const roleHierarchy = {
    'ADMIN': 3,
    'OWNER': 2,
    'PLAYER': 1
  }
  return roleHierarchy[userRole] >= roleHierarchy[requiredRole]
}

API Routes

Proxy Route Handler

All backend API calls are proxied through /api/proxy/*:
import {auth0} from '@/lib/auth0'
import {NextRequest, NextResponse} from 'next/server'

export const runtime = 'nodejs'

const API_URL = process.env.API_URL
if (!API_URL) throw new Error('API_URL is not set')

async function handler(request: NextRequest) {
  // Get session and token
  const session = await auth0.getSession()
  const token = session?.tokenSet?.accessToken
  
  if (!token) {
    return NextResponse.json(
      {message: 'Unauthorized'},
      {status: 401}
    )
  }
  
  // Extract path from /api/proxy/...
  const path = request.nextUrl.pathname.replace(/^\/api\/proxy/, '') || '/'
  const url = new URL(path + request.nextUrl.search, API_URL)
  
  // Forward request with auth
  const headers = new Headers()
  headers.set('Authorization', `Bearer ${token}`)
  headers.set('Accept-Encoding', 'identity')
  
  const ct = request.headers.get('content-type')
  if (ct) headers.set('content-type', ct)
  
  const method = request.method
  const hasBody = !['GET', 'HEAD'].includes(method)
  
  const upstream = await fetch(url, {
    method,
    headers,
    body: hasBody ? request.body : undefined,
    duplex: hasBody ? 'half' : undefined,
    redirect: 'manual'
  } as RequestInit)
  
  const body = await upstream.arrayBuffer()
  
  return new NextResponse(body, {
    status: upstream.status,
    headers: responseHeaders(upstream)
  })
}

export const GET = handler
export const POST = handler
export const PUT = handler
export const PATCH = handler
export const DELETE = handler
The proxy route automatically handles authentication, CORS, and content negotiation. Client components never directly access the backend API.

Data Fetching Patterns

Server Component Fetching

app/venue/[id]/page.tsx
import {auth0} from '@/lib/auth0'
import {apiFetch} from '@/lib/api'
import {notFound} from 'next/navigation'
import type {Venue} from '@/types'

interface Props {
  params: Promise<{id: string}>
}

export default async function VenuePage({params}: Props) {
  const {id} = await params
  const session = await auth0.getSession()
  
  // Fetch on server
  try {
    const venue = await apiFetch<Venue>(`/api/venues/${id}`)
    return <VenueDetail venue={venue} />
  } catch (error) {
    notFound()
  }
}

// Generate metadata
export async function generateMetadata({params}: Props) {
  const {id} = await params
  const venue = await apiFetch<Venue>(`/api/venues/${id}`)
  return {
    title: `${venue.name} - SportsHub`,
    description: venue.description
  }
}

Client Component Fetching

components/match/match-search-client.tsx
'use client'

import {useState, useEffect} from 'react'
import {apiFetchClient} from '@/lib/api'
import {useToast} from '@/hooks/use-toast'
import type {Match} from '@/types'

export function MatchSearchClient() {
  const [matches, setMatches] = useState<Match[]>([])
  const [loading, setLoading] = useState(true)
  const {toast} = useToast()
  
  useEffect(() => {
    async function loadMatches() {
      try {
        const data = await apiFetchClient<Match[]>('/api/matches')
        setMatches(data)
      } catch (error) {
        toast({
          title: 'Error loading matches',
          description: error.message,
          variant: 'destructive'
        })
      } finally {
        setLoading(false)
      }
    }
    loadMatches()
  }, [])
  
  if (loading) return <Spinner />
  return <MatchList matches={matches} />
}
import Link from 'next/link'
import {Button} from '@/components/ui/button'

// Basic link
<Link href="/dashboard">Dashboard</Link>

// With button
<Button asChild>
  <Link href="/match/create">Create Match</Link>
</Button>

// Dynamic route
<Link href={`/venue/${venue.id}`}>{venue.name}</Link>

// With query params
<Link href={{pathname: '/match/search', query: {city: 'Madrid'}}}>
  Madrid Matches
</Link>

Programmatic Navigation

'use client'

import {useRouter} from 'next/navigation'
import {Button} from '@/components/ui/button'

export function BookingButton({resourceId}: {resourceId: string}) {
  const router = useRouter()
  
  async function handleBooking() {
    await apiFetchClient('/api/bookings', {
      method: 'POST',
      body: JSON.stringify({resourceId})
    })
    
    // Navigate after booking
    router.push('/dashboard/bookings')
    
    // Or refresh current page data
    router.refresh()
  }
  
  return <Button onClick={handleBooking}>Book Now</Button>
}

Redirect

import {redirect} from 'next/navigation'

// Server component redirect
export default async function Page() {
  const session = await auth0.getSession()
  if (!session) redirect('/login')
  
  // Render page
}

// Permanent redirect (308)
redirect('/new-path', 'replace')

Loading States

Loading UI

Create loading.tsx for automatic loading states:
app/match/loading.tsx
import {Skeleton} from '@/components/ui/skeleton'

export default function Loading() {
  return (
    <div className="container mx-auto p-6">
      <Skeleton className="h-12 w-64 mb-6" />
      <div className="grid gap-4">
        {[...Array(5)].map((_, i) => (
          <Skeleton key={i} className="h-32 w-full" />
        ))}
      </div>
    </div>
  )
}

Error Handling

Create error.tsx for error boundaries:
app/match/error.tsx
'use client'

import {useEffect} from 'react'
import {Button} from '@/components/ui/button'
import {Alert, AlertTitle, AlertDescription} from '@/components/ui/alert'

export default function Error({
  error,
  reset
}: {
  error: Error & {digest?: string}
  reset: () => void
}) {
  useEffect(() => {
    console.error(error)
  }, [error])
  
  return (
    <div className="container mx-auto p-6">
      <Alert variant="destructive">
        <AlertTitle>Something went wrong</AlertTitle>
        <AlertDescription>{error.message}</AlertDescription>
      </Alert>
      <Button onClick={reset} className="mt-4">Try again</Button>
    </div>
  )
}

Authentication Flow

Login/Logout

components/auth-button.tsx
'use client'

import {Button} from '@/components/ui/button'

export function LoginButton() {
  return (
    <Button onClick={() => window.location.href = '/api/auth/login'}>
      Sign In
    </Button>
  )
}

export function LogoutButton() {
  return (
    <Button 
      variant="ghost"
      onClick={() => window.location.href = '/api/auth/logout'}
    >
      Sign Out
    </Button>
  )
}

Onboarding Flow

// After first login, redirect to onboarding
if (session && !user.hasCompletedOnboarding) {
  redirect('/onboarding')
}

Best Practices

Server Components First

Use Server Components for data fetching and authentication checks

Layout Protection

Protect entire route sections with layout authentication

Type Safety

Define TypeScript interfaces for all API responses

Error Handling

Implement error.tsx and loading.tsx for better UX

Next Steps

Frontend Structure

Learn about the Next.js app structure and directory organization

Components

Explore component patterns and shadcn/ui usage

Build docs developers (and LLMs) love