···
5
5
6
6
export default async function SSRProfilePage() {
7
7
const apiClient = await createServerApiClient();
8
8
-
8
8
+
9
9
let profile;
10
10
let error;
11
11
···
30
30
}
31
31
32
32
export interface TokenRefresher {
33
33
-
refreshTokens(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }>;
33
33
+
refreshTokens(
34
34
+
refreshToken: string,
35
35
+
): Promise<{ accessToken: string; refreshToken: string }>;
34
36
}
35
37
```
36
38
···
73
75
74
76
// Start refresh process
75
77
this.isRefreshing = true;
76
76
-
78
78
+
77
79
try {
78
80
// Use existing refresh promise or create new one
79
81
if (!this.refreshPromise) {
···
103
105
// Refresh failed, reject all queued requests
104
106
const queuedRequests = [...this.failedRequestsQueue];
105
107
this.failedRequestsQueue = [];
106
106
-
108
108
+
107
109
const refreshError = new Error('Token refresh failed');
108
110
queuedRequests.forEach(({ reject }) => reject(refreshError));
109
109
-
111
111
+
110
112
throw refreshError;
111
113
}
112
114
} finally {
···
121
123
122
124
try {
123
125
const newTokens = await this.refresher.refreshTokens(refreshToken);
124
124
-
await this.storage.setTokens(newTokens.accessToken, newTokens.refreshToken);
126
126
+
await this.storage.setTokens(
127
127
+
newTokens.accessToken,
128
128
+
newTokens.refreshToken,
129
129
+
);
125
130
return true;
126
131
} catch (error) {
127
132
console.error('Token refresh failed:', error);
···
143
148
if (typeof window === 'undefined') {
144
149
return { accessToken: null, refreshToken: null };
145
150
}
146
146
-
151
151
+
147
152
return {
148
153
accessToken: localStorage.getItem('accessToken'),
149
154
refreshToken: localStorage.getItem('refreshToken'),
···
152
157
153
158
async setTokens(accessToken: string, refreshToken: string): Promise<void> {
154
159
if (typeof window === 'undefined') return;
155
155
-
160
160
+
156
161
localStorage.setItem('accessToken', accessToken);
157
162
localStorage.setItem('refreshToken', refreshToken);
158
158
-
163
163
+
159
164
// Sync with server cookies
160
165
try {
161
166
await fetch('/api/auth/sync', {
···
210
215
export class ApiTokenRefresher implements TokenRefresher {
211
216
constructor(private baseUrl: string) {}
212
217
213
213
-
async refreshTokens(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
218
218
+
async refreshTokens(
219
219
+
refreshToken: string,
220
220
+
): Promise<{ accessToken: string; refreshToken: string }> {
214
221
const response = await fetch(`${this.baseUrl}/api/auth/refresh`, {
215
222
method: 'POST',
216
223
headers: { 'Content-Type': 'application/json' },
···
260
267
headers,
261
268
};
262
269
263
263
-
if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
270
270
+
if (
271
271
+
data &&
272
272
+
(method === 'POST' || method === 'PUT' || method === 'PATCH')
273
273
+
) {
264
274
config.body = JSON.stringify(data);
265
275
}
266
276
···
317
327
export const createClientTokenManager = () => {
318
328
const storage = new ClientTokenStorage();
319
329
const refresher = new ApiTokenRefresher(
320
320
-
process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000'
330
330
+
process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000',
321
331
);
322
332
return new TokenManager(storage, refresher);
323
333
};
···
379
389
const tokenManager = useMemo(() => createClientTokenManager(), []);
380
390
381
391
// Helper function to check if a JWT token is expired or will expire soon
382
382
-
const isTokenExpired = (token: string, bufferMinutes: number = 5): boolean => {
392
392
+
const isTokenExpired = (
393
393
+
token: string,
394
394
+
bufferMinutes: number = 5,
395
395
+
): boolean => {
383
396
if (!token) return true;
384
397
385
398
try {
386
399
const payload = JSON.parse(atob(token.split('.')[1]));
387
400
const expiry = payload.exp * 1000; // Convert to milliseconds
388
401
const bufferTime = bufferMinutes * 60 * 1000; // Buffer in milliseconds
389
389
-
return Date.now() >= (expiry - bufferTime);
402
402
+
return Date.now() >= expiry - bufferTime;
390
403
} catch (e) {
391
404
return true;
392
405
}
···
406
419
407
420
// Check immediately on mount
408
421
checkAndRefreshToken();
409
409
-
422
422
+
410
423
// Then check every 5 minutes
411
424
const interval = setInterval(checkAndRefreshToken, 5 * 60 * 1000);
412
425
···
427
440
- Server-side rendering scenarios
428
441
429
442
The TokenManager automatically:
443
443
+
430
444
1. **Detects auth errors** (401/403) from API responses
431
445
2. **Refreshes tokens** using the refresh token
432
446
3. **Retries the original request** with fresh tokens
···
477
491
478
492
export default async function SSRProfilePage() {
479
493
const apiClient = await createServerApiClient();
480
480
-
494
494
+
481
495
let profile;
482
496
let error;
483
497
···
502
516
✅ **Race Condition Safe**: TokenManager prevents concurrent refreshes
503
517
✅ **Automatic Recovery**: Failed requests are automatically retried after refresh
504
518
✅ **Request Queuing**: Multiple concurrent requests handled gracefully
505
505
-
✅ **Server-Side Support**: Works in SSR with cookie-based tokens
519
519
+
✅ **Server-Side Support**: Works in SSR with cookie-based tokens
506
520
507
521
## Server-Side Token Refresh Limitation
508
522
509
523
**Current Approach**: Server components use read-only token access from cookies. If tokens are expired, the request will fail gracefully.
510
524
511
525
**Why This Works**:
526
526
+
512
527
- Proactive refresh on client-side prevents most expiration scenarios
513
528
- Server components typically render fresh on each request
514
529
- Failed server requests degrade gracefully (show error states)
515
530
- Client-side components can still refresh and retry
516
531
517
532
**Future Enhancements** (if needed):
533
533
+
518
534
- Add middleware to check token expiry and redirect to refresh endpoint
519
535
- Implement server-side refresh with secure cookie updates
520
536
- Use hybrid approach with client-side refresh signals
···
527
543
- **Refresh Token Lifetime**: 7 days (server-configured)
528
544
529
545
This means:
546
546
+
530
547
- **99% of cases**: Tokens refreshed proactively at 10 minutes (before 15-minute expiry)
531
548
- **1% of cases**: Reactive refresh handles edge cases when proactive refresh missed
532
549
- **Zero failed requests**: All auth errors automatically trigger refresh and retry
···
569
586
570
587
```typescript
571
588
// Multiple API calls happen when token is expired
572
572
-
apiClient.getProfile(); // Triggers refresh
573
573
-
apiClient.getCards(); // Queued, waits for refresh
589
589
+
apiClient.getProfile(); // Triggers refresh
590
590
+
apiClient.getCards(); // Queued, waits for refresh
574
591
apiClient.getCollections(); // Queued, waits for refresh
575
592
576
593
// After refresh completes:
···
581
598
## Monitoring
582
599
583
600
Consider tracking these metrics:
601
601
+
584
602
- **Proactive refresh frequency** (should be regular, every ~5 minutes)
585
603
- **Reactive refresh frequency** (should be rare, <1% of API calls)
586
604
- **Failed refresh attempts** (should investigate if >0.1%)
···
593
611
### From Old Approach
594
612
595
613
**Before:**
614
614
+
596
615
```typescript
597
616
const apiClient = new ApiClient(
598
617
baseUrl,
···
602
621
```
603
622
604
623
**After:**
624
624
+
605
625
```typescript
606
626
import { apiClient } from '@/api-client';
607
627
// Just use the default instance - no configuration needed!
···
29
29
headers,
30
30
};
31
31
32
32
-
if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
32
32
+
if (
33
33
+
data &&
34
34
+
(method === 'POST' || method === 'PUT' || method === 'PATCH')
35
35
+
) {
33
36
config.body = JSON.stringify(data);
34
37
}
35
38
···
5
5
if (typeof window === 'undefined') {
6
6
return { accessToken: null, refreshToken: null };
7
7
}
8
8
-
8
8
+
9
9
return {
10
10
accessToken: localStorage.getItem('accessToken'),
11
11
refreshToken: localStorage.getItem('refreshToken'),
···
14
14
15
15
async setTokens(accessToken: string, refreshToken: string): Promise<void> {
16
16
if (typeof window === 'undefined') return;
17
17
-
17
17
+
18
18
localStorage.setItem('accessToken', accessToken);
19
19
localStorage.setItem('refreshToken', refreshToken);
20
20
-
20
20
+
21
21
// Sync with server cookies
22
22
try {
23
23
await fetch('/api/auth/sync', {