Skip to content

Authentication Frontend Implementation

Frontend authentication implementation using Better Auth React client, React Query for state management, and TanStack Router for route protection.

Overview

The frontend authentication is provided through the centralized @jubiloop/api-client package:

  • Better Auth client for authentication operations (configured in api-client)
  • React Query for auth state management (hooks from api-client)
  • TanStack Router guards for protected routes
  • Form components for login/registration
  • Organization support with role-based access

Authentication State Management

React Query as Auth State

Authentication is managed through the @jubiloop/api-client package hooks, providing a consistent interface across all apps:

typescript
// apps/webapp/src/hooks/auth/use-auth.ts
import { api } from '@/lib/api/setup'

export function useAuth() {
  // Uses the centralized auth hooks from @jubiloop/api-client
  return api.hooks.auth.useAuth()
}

// The actual implementation is in packages/api-client/src/hooks/auth.ts
// providing consistent behavior across all frontend apps

Session Query Configuration

Session queries are now centralized in the @jubiloop/api-client package:

typescript
// packages/api-client/src/queries/session.ts
// Uses React Query defaults for optimal performance:
// - staleTime: Infinity (auth state doesn't go stale)
// - gcTime: 5 minutes (default garbage collection)
// - refetchOnMount: true (verify auth on mount)
// - refetchOnWindowFocus: true (security check on return)

This simplified configuration:

  • Leverages React Query's smart defaults
  • Reduces configuration overhead
  • Ensures consistent behavior across all apps
  • Updates automatically on login/logout

Organization State Management

Query keys are centralized in the @jubiloop/api-client package and re-exported by webapp:

typescript
// packages/api-client/src/keys/defaults.ts
export const defaultQueryKeys = {
  all: ['api'] as const,
  auth: {
    all: ['api', 'auth'] as const,
    session: () => ['api', 'auth', 'session'] as const,
    organizations: {
      all: ['api', 'auth', 'organizations'] as const,
      list: () => ['api', 'auth', 'organizations', 'list'] as const,
      active: () => ['api', 'auth', 'organizations', 'active'] as const,
      detail: (id: string) => ['api', 'auth', 'organizations', 'detail', id] as const,
    },
  },
}

// apps/webapp/src/integrations/tanstack-query/keys.ts
import { defaultQueryKeys } from '@jubiloop/api-client/keys/defaults'
export const queryKeys = defaultQueryKeys // Re-export for convenience

This centralization ensures:

  • Consistent cache keys across all apps
  • Granular cache invalidation
  • No accidental key collisions

API Client Setup

Authentication is configured through the centralized API package:

typescript
// apps/webapp/src/lib/api/setup.ts
import { createApiPackage } from '@jubiloop/api-client'

export const api = createApiPackage({
  baseURL: import.meta.env.VITE_API_URL || 'https://api.jubiloop.localhost',
  onUnauthorized: () => {
    queryClient.invalidateQueries({ queryKey: queryKeys.auth.all })
  },
})

// The Better Auth client is created internally in packages/api-client/src/auth/client.ts
// with organization plugin already configured

Authentication Flows

Sign In Flow

1. Sign In Form Component

Located in src/components/auth/SignInForm.tsx:

typescript
const SignInForm = () => {
  const { signIn, isLoading } = useAuth()

  const onSubmit = (data: TSignInFormValues) => {
    signIn(data, {
      onSuccess: () => {
        // Optional success callback
      },
      onError: (error) => {
        // Optional error callback
      },
    })
  }
}

2. Authentication Process

The sign-in flow:

  1. Better Auth client sends credentials to /auth/sign-in
  2. Backend validates credentials and creates session
  3. Session stored in Redis with secure cookie
  4. React Query invalidates auth queries
  5. All components using useAuth() automatically update
  6. Router redirects to dashboard

3. Session Management

Sessions are managed server-side with:

  • HTTP-only, Secure, SameSite cookies
  • Redis storage for horizontal scaling
  • Configurable expiration (7 days default, 30 days with remember me)
  • Automatic renewal on activity

Sign Out Flow

typescript
const { signOut } = useAuth()

// Sign out with optional callbacks
signOut({
  onSuccess: () => {
    // Optional: Additional cleanup
  },
  onError: (error) => {
    // Handle error
  },
})

The sign-out process:

  1. Calls Better Auth's sign-out endpoint
  2. Clears session on backend
  3. Invalidates all auth queries
  4. Router redirects to sign-in page

Protected Routes

Route protection is handled in src/routes/__root.tsx:

typescript
export const Route = createRootRouteWithContext<MyRouterContext>()({
  beforeLoad: async ({ location, context }) => {
    const isAuthRoute =
      location.pathname.startsWith('/sign-in') || location.pathname.startsWith('/sign-up')

    // Get session from cache or fetch if needed
    const sessionData = await getSessionData(context.queryClient)
    const authenticated = isAuthenticated(sessionData)

    // Redirect logic
    if (isAuthRoute && authenticated) {
      throw redirect({ to: '/dashboard' })
    }

    if (!isAuthRoute && !authenticated) {
      throw redirect({
        to: '/sign-in',
        search: { redirect: location.href },
      })
    }
  },
})

The route guard pattern:

  • Uses query to check auth state
  • Checks cache first to avoid unnecessary API calls
  • Only fetches from API if no cached session exists
  • Redirects based on authentication status

Session Validation

Session validation happens automatically through React Query:

typescript
// src/lib/auth/queries/session.ts
export const getSessionData = async (queryClient: QueryClient) => {
  // Try to get from cache first
  const cachedData = queryClient.getQueryData(queryKeys.auth.session())
  if (cachedData) return cachedData

  // If not in cache, fetch from API
  try {
    return await queryClient.fetchQuery(sessionQueryOptions())
  } catch {
    return null
  }
}

Registration Flow

Sign Up Form

Located in src/components/auth/SignUpForm.tsx:

typescript
const SignUpForm = () => {
  const { signUp, isLoading } = useAuth()

  const onSubmit = (data: TSignUpFormValues) => {
    signUp(data, {
      onSuccess: () => {
        // Auto-redirects to dashboard
      },
      onError: (error) => {
        // Handle error
      },
    })
  }
}

Form Validation

Using React Hook Form with Zod:

typescript
const signUpSchema = z
  .object({
    email: z.string().email('Invalid email address'),
    password: z
      .string()
      .min(8, 'Password must be at least 8 characters')
      .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]+$/),
    name: z.string().min(1, 'Name is required'),
    passwordConfirmation: z.string(),
  })
  .refine((data) => data.password === data.passwordConfirmation, {
    message: "Passwords don't match",
    path: ['passwordConfirmation'],
  })

Remember Me Feature

The "Remember Me" checkbox extends session duration:

typescript
// In login form
<Checkbox
  name="rememberMe"
  label="Remember me for 30 days"
/>

// Better Auth handles session duration based on rememberMe
// - true: Session lasts 30 days
// - false: Session lasts 7 days (default)
// Configuration in apps/server/app/lib/auth.ts

Error Handling

Invalid Credentials

Error handling is built into the auth hooks:

typescript
const { signIn } = useAuth()

signIn(credentials, {
  onError: (error) => {
    // Better Auth provides detailed error messages
    toast.error(error.message || 'Invalid credentials')
  },
})

Session Expired

When a session expires:

  1. API returns 401 status
  2. API client interceptor detects unauthorized response
  3. All auth queries are invalidated
  4. User automatically redirected to sign-in
  5. Original URL preserved for post-login redirect

Security Best Practices

  1. HTTP-Only Cookies: Session tokens not accessible via JavaScript
  2. CSRF Protection: SameSite cookie attribute
  3. Secure Flag: Cookies only sent over HTTPS in production
  4. Password Requirements: Minimum 8 characters enforced
  5. Rate Limiting: Login attempts limited on backend

Built with ❤️ by the Jubiloop team