Files
gridpilot.gg/docs/architecture/website/SESSION.md
2026-01-13 23:33:07 +01:00

8.3 KiB

Next.js RSC Session Best Practices

This document defines the authoritative pattern for handling session/authentication in Next.js Server Components and Server Actions within the apps/website layer.

Core Principle

Server Actions should NOT fetch session separately. The API handles authentication automatically via cookies.

The Problem with Manual Session Fetching

Anti-Pattern (Current Implementation)

// apps/website/app/onboarding/actions.ts
'use server';

import { SessionGateway } from '@/lib/gateways/SessionGateway';

async function getCurrentUserId(): Promise<string | null> {
  const gateway = new SessionGateway();
  const session = await gateway.getSession();  // ❌ Extra API call
  return session?.user?.userId || null;
}

export async function completeOnboardingAction(input: CompleteOnboardingInputDTO) {
  const userId = await getCurrentUserId();  // ❌ Performance overhead
  if (!userId) {
    return Result.err('Not authenticated');
  }
  
  const mutation = new CompleteOnboardingMutation();
  // ... rest of logic
}

Problems:

  1. Performance: Makes extra API call on every action invocation
  2. Redundancy: Manual auth check when API handles it automatically
  3. Coupling: Actions depend on session infrastructure
  4. Inconsistency: Doesn't match pattern used elsewhere in codebase

The Correct Pattern

Server Actions (Thin Wrappers)

// apps/website/app/onboarding/actions.ts
'use server';

import { Result } from '@/lib/contracts/Result';
import { CompleteOnboardingMutation } from '@/lib/mutations/onboarding/CompleteOnboardingMutation';
import { GenerateAvatarsMutation } from '@/lib/mutations/onboarding/GenerateAvatarsMutation';
import { CompleteOnboardingInputDTO } from '@/lib/types/generated/CompleteOnboardingInputDTO';
import { revalidatePath } from 'next/cache';

/**
 * Complete onboarding - thin wrapper around mutation
 * 
 * Pattern: Server Action → Mutation → Service → API Client
 * 
 * Authentication is handled automatically by the API via cookies.
 * The BaseApiClient includes credentials: 'include', so cookies are sent automatically.
 * If authentication fails, the API returns 401/403 which gets converted to domain errors.
 */
export async function completeOnboardingAction(
  input: CompleteOnboardingInputDTO
): Promise<Result<{ success: boolean }, string>> {
  const mutation = new CompleteOnboardingMutation();
  const result = await mutation.execute(input);
  
  if (result.isErr()) {
    return Result.err(result.getError());
  }
  
  revalidatePath('/dashboard');
  return Result.ok({ success: true });
}

/**
 * Generate avatars - thin wrapper around mutation
 * 
 * Note: This action requires userId to be passed from the client.
 * The client should get userId from session and pass it as a parameter.
 */
export async function generateAvatarsAction(params: {
  userId: string;
  facePhotoData: string;
  suitColor: string;
}): Promise<Result<{ success: boolean; avatarUrls?: string[] }, string>> {
  const mutation = new GenerateAvatarsMutation();
  const result = await mutation.execute(params);
  
  if (result.isErr()) {
    return Result.err(result.getError());
  }
  
  const data = result.unwrap();
  return Result.ok({ success: data.success, avatarUrls: data.avatarUrls });
}

Client Component Pattern

// apps/website/app/onboarding/OnboardingWizardClient.tsx
'use client';

import { completeOnboardingAction, generateAvatarsAction } from '@/app/onboarding/actions';
import { useAuth } from '@/lib/hooks/auth/useAuth';
import { useState } from 'react';

export function OnboardingWizardClient() {
  const { session } = useAuth(); // Get userId from session (client-side)
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleCompleteOnboarding = async (input: CompleteOnboardingInputDTO) => {
    setIsSubmitting(true);
    try {
      const result = await completeOnboardingAction(input);
      
      if (result.isErr()) {
        // Handle error (show toast, etc.)
        console.error('Onboarding failed:', result.getError());
        return;
      }
      
      // Success - redirect or show success message
      console.log('Onboarding completed successfully');
    } finally {
      setIsSubmitting(false);
    }
  };

  const handleGenerateAvatars = async (facePhotoData: string, suitColor: string) => {
    if (!session?.user?.userId) {
      console.error('User not authenticated');
      return Result.err('Not authenticated');
    }

    setIsSubmitting(true);
    try {
      const result = await generateAvatarsAction({
        userId: session.user.userId,  // Pass userId from session
        facePhotoData,
        suitColor,
      });

      if (result.isErr()) {
        console.error('Avatar generation failed:', result.getError());
        return;
      }

      const data = result.unwrap();
      console.log('Avatars generated:', data.avatarUrls);
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <div>
      {/* Wizard implementation */}
      <button onClick={() => handleCompleteOnboarding({ firstName: 'John', lastName: 'Doe', displayName: 'JohnD', country: 'US' })}>
        Complete Onboarding
      </button>
    </div>
  );
}

Why This Pattern Works

1. Automatic Authentication

// apps/website/lib/api/base/BaseApiClient.ts
protected async request<T>(method: string, path: string, data?: object, options = {}): Promise<T> {
  const config: RequestInit = {
    method,
    headers,
    credentials: 'include',  // ✅ Automatically sends cookies
    signal,
  };
  // ...
}

The BaseApiClient automatically includes credentials: 'include', so:

  • Cookies are sent with every API request
  • The backend API can authenticate the user
  • No manual session fetching needed in server actions

2. Error Handling Flow

User not authenticated
  ↓
API returns 401/403
  ↓
BaseApiClient throws HttpUnauthorizedError
  ↓
Service catches and converts to DomainError
  ↓
Mutation returns Result.err()
  ↓
Server Action returns error to client
  ↓
Client shows appropriate message

3. Performance Benefits

  • No extra API calls: Session fetched once on client, reused
  • Reduced latency: Server actions don't wait for session lookup
  • Better scalability: Fewer API calls per user action

4. Consistency with Architecture

This pattern matches your existing codebase:

// apps/website/app/admin/actions.ts - ✅ Already follows this pattern
export async function updateUserStatus(userId: string, status: string) {
  const mutation = new UpdateUserStatusMutation();
  const result = await mutation.execute({ userId, status });
  // ... no manual session fetching
}

When Manual Session Fetching IS Needed

There are rare cases where you might need to fetch session in a server action:

Case 1: User ID Required in Request Body

If the API endpoint requires userId in the request body (not just cookies):

// Client passes userId
const result = await generateAvatarsAction({
  userId: session.user.userId,  // ✅ Pass from client
  facePhotoData,
  suitColor,
});

Case 2: Server-Side Authorization Check

If you need to check permissions before calling a mutation:

export async function adminAction(input: AdminInput) {
  // Only fetch session if you need to check permissions server-side
  const gateway = new SessionGateway();
  const session = await gateway.getSession();
  
  if (!session?.user?.roles?.includes('admin')) {
    return Result.err('Forbidden');
  }
  
  const mutation = new AdminMutation();
  return await mutation.execute(input);
}

Note: This is rare. Usually, the API handles authorization and returns 403 if unauthorized.

Summary

Aspect Manual Session Fetching Automatic (Recommended)
Performance Extra API call No overhead
Complexity More code Simpler
Coupling Depends on session infra Decoupled
Consistency Inconsistent Matches codebase
Security ⚠️ Manual checks API enforces

Golden Rule

Server Actions are thin wrappers. Mutations handle logic. API enforces security.

Never fetch session in server actions unless you have a specific server-side authorization requirement that cannot be handled by the API.