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:
- Performance: Makes extra API call on every action invocation
- Redundancy: Manual auth check when API handles it automatically
- Coupling: Actions depend on session infrastructure
- 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.