From fullstack-agents
Client and server-side fetch utilities for Next.js applications with two distinct data paths: direct backend calls (server) and API route proxying (client).
npx claudepluginhub adelabdelgawad/fullstack-agents --plugin fullstack-agentsThis skill uses the workspace's default tool permissions.
Client and server-side fetch utilities for Next.js applications with two distinct data paths: direct backend calls (server) and API route proxying (client).
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Checks Next.js compilation errors using a running Turbopack dev server after code edits. Fixes actionable issues before reporting complete. Replaces `next build`.
Client and server-side fetch utilities for Next.js applications with two distinct data paths: direct backend calls (server) and API route proxying (client).
Use this skill when asked to:
┌─────────────────────────────────────────────────────────────┐
│ Browser (Client) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Client Components │ │
│ │ • api.get/post/put/delete │ │
│ │ • CSRF via csrfManager │ │
│ │ • URL auto-prefix (/setting/x → /api/setting/x) │ │
│ └──────────────────────────┬──────────────────────────┘ │
└─────────────────────────────┼───────────────────────────────┘
│ HTTP (cookies)
▼
┌─────────────────────────────────────────────────────────────┐
│ Next.js Server │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ API Routes (app/api/...) ← client only │ │
│ │ • withAuth(token, forwardHeaders) │ │
│ │ • backendFetch() from backend.ts │ │
│ │ • Pre-emptive token refresh │ │
│ │ • CSRF forwarding │ │
│ └──────────────────────────┬──────────────────────────┘ │
│ │ HTTP (Bearer token) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Server Actions (lib/actions/) ← SSR path │ │
│ │ • directBackendFetch() — calls backend DIRECTLY │ │
│ │ • getAccessToken() + getBackendURL() │ │
│ │ • CSRF via getServerCsrfToken() for mutations │ │
│ └──────────────────────────┬──────────────────────────┘ │
│ │ HTTP (Bearer token) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Server Components (pages) │ │
│ │ • Call server actions for SSR data │ │
│ │ • Layout handles auth check │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────┼───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ FastAPI Backend │
│ /backend/setting/... /backend/management/... │
└─────────────────────────────────────────────────────────────┘
Server actions MUST call the backend directly via directBackendFetch().
Routing server actions through Next.js API routes is FORBIDDEN (unnecessary 2-hop overhead).
// ❌ WRONG — server action routing through API route (2 hops)
import { serverGet } from '@/lib/fetch/server';
const data = await serverGet('/api/setting/users');
// ❌ WRONG — server action calling API route URL
const res = await fetch('http://localhost:3000/api/setting/users', {
headers: { Cookie: cookieHeader }
});
// ✅ CORRECT — server action calls backend directly (1 hop)
import { serverGet } from '@/lib/fetch/server';
const data = await serverGet<UsersResponse>('/backend/setting/users');
Rule: Server actions always use /backend/... URLs. API routes are only for client-side requests.
lib/
├── fetch/
│ ├── index.ts # Exports
│ ├── client.ts # Client-side fetch (browser) — exports `api`
│ ├── server.ts # Server-side fetch (direct backend calls)
│ ├── backend.ts # Backend fetch (used in API routes only)
│ ├── api-route-helper.ts # API route wrappers (withAuth)
│ ├── errors.ts # Error classes
│ └── types.ts # TypeScript types
└── auth/
├── server-auth.ts # Server authentication (getAccessToken, auth)
├── auth-cookies.ts # Cookie management (getBackendURL)
├── auth-service.ts # Client auth (token refresh)
├── csrf-manager.ts # Client CSRF token management
├── redirect-guard.ts # Redirect loop detection
└── auth-logger.ts # Auth event logging
// lib/fetch/errors.ts
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public data?: unknown
) {
super(message);
this.name = 'ApiError';
}
}
export function extractErrorMessage(data: unknown, status: number): string {
if (typeof data === 'string') return data;
if (typeof data === 'object' && data !== null) {
const obj = data as Record<string, unknown>;
if (typeof obj.detail === 'string') return obj.detail;
if (typeof obj.message === 'string') return obj.message;
if (typeof obj.error === 'string') return obj.error;
}
return 'An error occurred';
}
// lib/fetch/types.ts
export interface FetchOptions {
headers?: Record<string, string>;
timeout?: number;
cache?: RequestCache;
next?: NextFetchRequestConfig;
}
export interface FetchRequestOptions extends FetchOptions {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: unknown;
}
// lib/fetch/server.ts
"use server";
/**
* Server-side fetch utilities for server actions.
* All requests call the backend directly (single hop).
* Mutations include a CSRF token fetched from the backend.
*/
import { getAccessToken } from '@/lib/auth/server-auth';
import { getBackendURL } from '@/lib/auth/auth-cookies';
import { ApiError, extractErrorMessage } from './errors';
import type { FetchOptions, FetchRequestOptions } from './types';
const DEFAULT_TIMEOUT = 30000;
async function getServerCsrfToken(accessToken: string): Promise<string | null> {
try {
const backendUrl = getBackendURL();
const response = await fetch(`${backendUrl}/backend/csrf-token`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
cache: 'no-store',
});
if (!response.ok) return null;
const data = await response.json();
return data.csrfToken ?? data.csrf_token ?? null;
} catch {
return null;
}
}
async function directBackendFetch<T>(
url: string,
options: FetchRequestOptions = {}
): Promise<T> {
const token = await getAccessToken();
if (!token) {
throw new ApiError('Not authenticated', 401);
}
const backendUrl = getBackendURL();
const fullUrl = `${backendUrl}${url}`;
const method = options.method || 'GET';
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
options.timeout || DEFAULT_TIMEOUT
);
// Fetch CSRF token for state-changing requests
const csrfHeaders: Record<string, string> = {};
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
const csrfToken = await getServerCsrfToken(token);
if (csrfToken) {
csrfHeaders['X-CSRF-Token'] = csrfToken;
}
}
try {
const response = await fetch(fullUrl, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'X-Request-ID': crypto.randomUUID(),
...csrfHeaders,
...options.headers,
},
body: options.body ? JSON.stringify(options.body) : undefined,
signal: controller.signal,
cache: options.cache ?? 'no-store',
next: options.next,
});
let data: unknown;
try { data = await response.json(); } catch { data = {}; }
if (!response.ok) {
throw new ApiError(extractErrorMessage(data, response.status), response.status, data);
}
return data as T;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new ApiError('Request timeout', 408);
}
if (error instanceof ApiError) { throw error; }
throw new ApiError(
error instanceof Error ? error.message : 'Network error', 500
);
} finally {
clearTimeout(timeoutId);
}
}
export async function serverGet<T>(url: string, opts?: FetchOptions): Promise<T> {
return directBackendFetch<T>(url, { ...opts, method: 'GET' });
}
export async function serverPost<T>(url: string, body: unknown, opts?: FetchOptions): Promise<T> {
return directBackendFetch<T>(url, { ...opts, method: 'POST', body });
}
export async function serverPut<T>(url: string, body: unknown, opts?: FetchOptions): Promise<T> {
return directBackendFetch<T>(url, { ...opts, method: 'PUT', body });
}
export async function serverDelete<T>(url: string, opts?: FetchOptions & { body?: unknown }): Promise<T> {
return directBackendFetch<T>(url, { ...opts, method: 'DELETE', body: opts?.body });
}
// lib/fetch/client.ts
"use client";
import { AuthService } from '@/lib/auth/auth-service';
import { authLogger } from '@/lib/auth/auth-logger';
import { redirectGuard } from '@/lib/auth/redirect-guard';
import { csrfManager } from '@/lib/auth/csrf-manager';
import { ApiError, extractErrorMessage } from './errors';
import type { FetchOptions, FetchRequestOptions } from './types';
const DEFAULT_TIMEOUT = 30000;
const MAX_RETRIES = 2;
const RETRY_DELAY = 1000;
async function clientFetch<T>(
url: string,
options: FetchRequestOptions = {},
attempt = 1,
isRetryAfterRefresh = false
): Promise<T> {
// Auto-prefix: /setting/roles → /api/setting/roles
const fullUrl = url.startsWith('/api') ? url : `/api${url}`;
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
options.timeout || DEFAULT_TIMEOUT
);
try {
const method = options.method || 'GET';
// Include CSRF token for state-changing requests
const csrfHeaders: Record<string, string> = {};
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
try {
const csrfToken = await csrfManager.getToken();
csrfHeaders['X-CSRF-Token'] = csrfToken;
} catch {
// Continue without CSRF token
}
}
const response = await fetch(fullUrl, {
method,
headers: {
'Content-Type': 'application/json',
'X-Request-ID': globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2) + Date.now().toString(36),
...csrfHeaders,
...options.headers,
},
body: options.body ? JSON.stringify(options.body) : undefined,
signal: controller.signal,
credentials: 'include',
});
let data: unknown;
try { data = await response.json(); } catch { data = {}; }
if (!response.ok) {
if (response.status === 401 && !isRetryAfterRefresh) {
clearTimeout(timeoutId);
authLogger.logTokenExpiry({ url: fullUrl });
try {
const newToken = await AuthService.refreshAccessToken();
if (newToken) {
authLogger.logRefreshAttempt(true);
return clientFetch<T>(url, options, attempt, true);
}
} catch (error) {
authLogger.logRefreshAttempt(false, error instanceof Error ? error.message : 'Unknown error');
}
if (typeof window !== 'undefined') {
if (!redirectGuard.canRedirect()) {
redirectGuard.showLoopError();
throw new ApiError('Session expired - redirect loop detected', 401);
}
authLogger.logRedirect('Session expired', window.location.href);
redirectGuard.recordRedirect();
window.location.href = '/login?auth_error=session_expired';
}
throw new ApiError('Session expired', 401);
}
if ((response.status === 429 || response.status === 503) && attempt < MAX_RETRIES) {
clearTimeout(timeoutId);
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * attempt));
return clientFetch<T>(url, options, attempt + 1, isRetryAfterRefresh);
}
throw new ApiError(extractErrorMessage(data, response.status), response.status, data);
}
return data as T;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new ApiError('Request timeout', 408);
}
if (error instanceof ApiError) throw error;
throw new ApiError(error instanceof Error ? error.message : 'Network error', 500);
} finally {
clearTimeout(timeoutId);
}
}
/**
* Client API — returns T directly (no wrapping)
*/
export const api = {
get: <T>(url: string, opts?: FetchOptions): Promise<T> =>
clientFetch<T>(url, { ...opts, method: 'GET' }),
post: <T>(url: string, body: unknown, opts?: FetchOptions): Promise<T> =>
clientFetch<T>(url, { ...opts, method: 'POST', body }),
put: <T>(url: string, body: unknown, opts?: FetchOptions): Promise<T> =>
clientFetch<T>(url, { ...opts, method: 'PUT', body }),
patch: <T>(url: string, body: unknown, opts?: FetchOptions): Promise<T> =>
clientFetch<T>(url, { ...opts, method: 'PATCH', body }),
delete: <T>(url: string, opts?: FetchOptions): Promise<T> =>
clientFetch<T>(url, { ...opts, method: 'DELETE' }),
};
export default api;
// lib/fetch/backend.ts
import { getBackendURL } from '@/lib/auth/auth-cookies';
import { ApiError, extractErrorMessage } from './errors';
export async function backendFetch<T>(
endpoint: string,
token: string,
options?: {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: unknown;
headers?: Record<string, string>;
}
): Promise<T> {
const backendUrl = getBackendURL();
const fullUrl = `${backendUrl}/backend${endpoint}`;
const response = await fetch(fullUrl, {
method: options?.method || 'GET',
headers: {
'Content-Type': 'application/json',
'X-Request-ID': crypto.randomUUID(),
Authorization: `Bearer ${token}`,
...options?.headers,
},
body: options?.body ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new ApiError(extractErrorMessage(errorData, response.status), response.status, errorData);
}
if (response.status === 204 || response.headers.get('content-length') === '0') {
return undefined as T;
}
return response.json() as Promise<T>;
}
// lib/fetch/api-route-helper.ts
import { NextResponse } from 'next/server';
import { headers, cookies } from 'next/headers';
import { auth } from '@/lib/auth/server-auth';
import { ApiError } from './errors';
import { setAuthCookies, clearAuthCookies, refreshTokenOnce } from '@/lib/auth/auth-cookies';
function isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
if (!payload?.exp) return true;
return payload.exp * 1000 < Date.now() + 30_000; // 30s buffer
} catch { return true; }
}
async function tryRefreshToken(): Promise<{
success: boolean;
accessToken: string | null;
setCookies: (response: NextResponse) => void;
}> {
const cookieStore = await cookies();
const refreshToken = cookieStore.get('refresh_token')?.value;
if (!refreshToken) return { success: false, accessToken: null, setCookies: () => {} };
const data = await refreshTokenOnce(refreshToken);
if (!data) return { success: false, accessToken: null, setCookies: () => {} };
return {
success: true,
accessToken: data.accessToken,
setCookies: (response: NextResponse) => setAuthCookies(response, data),
};
}
/**
* Wrap API route with authentication, CSRF forwarding, token refresh, and error handling.
*
* GET routes use (token) — no CSRF needed.
* Mutation routes use (token, headers) — forwards X-CSRF-Token.
*/
export async function withAuth<T>(
handler: (token: string, forwardHeaders: Record<string, string>) => Promise<T>
): Promise<NextResponse> {
let accessToken: string | null = null;
let refreshResult: { success: boolean; accessToken: string | null; setCookies: (r: NextResponse) => void } | null = null;
try {
const session = await auth();
if (!session?.accessToken) {
return NextResponse.json({ detail: 'Unauthorized' }, { status: 401 });
}
accessToken = session.accessToken;
// Pre-emptive token refresh (30s buffer)
if (isTokenExpired(accessToken)) {
refreshResult = await tryRefreshToken();
if (!refreshResult.success || !refreshResult.accessToken) {
const response = NextResponse.json({ detail: 'Session expired' }, { status: 401 });
clearAuthCookies(response);
return response;
}
accessToken = refreshResult.accessToken;
}
// Forward CSRF header from client request
const forwardHeaders: Record<string, string> = {};
const requestHeaders = await headers();
const csrfToken = requestHeaders.get('X-CSRF-Token');
if (csrfToken) {
forwardHeaders['X-CSRF-Token'] = csrfToken;
}
const data = await handler(accessToken, forwardHeaders);
// Handle 204 No Content
if (data === undefined) {
const response = new NextResponse(null, { status: 204 });
if (refreshResult) refreshResult.setCookies(response);
return response;
}
const response = NextResponse.json(data);
if (refreshResult) refreshResult.setCookies(response);
return response;
} catch (error) {
if (error instanceof ApiError) {
// 401 retry with double-refresh safety
if (error.status === 401 && !refreshResult) {
const retryRefresh = await tryRefreshToken();
if (retryRefresh.success && retryRefresh.accessToken) {
try {
const fh: Record<string, string> = {};
const rh = await headers();
const csrf = rh.get('X-CSRF-Token');
if (csrf) fh['X-CSRF-Token'] = csrf;
const data = await handler(retryRefresh.accessToken, fh);
if (data === undefined) {
const response = new NextResponse(null, { status: 204 });
retryRefresh.setCookies(response);
return response;
}
const response = NextResponse.json(data);
retryRefresh.setCookies(response);
return response;
} catch (retryError) {
if (retryError instanceof ApiError) {
return NextResponse.json(
{ detail: retryError.message, ...(retryError.data && typeof retryError.data === 'object' ? retryError.data : {}) },
{ status: retryError.status }
);
}
throw retryError;
}
}
const response = NextResponse.json({ detail: error.message }, { status: 401 });
clearAuthCookies(response);
return response;
}
return NextResponse.json(
{ detail: error.message, ...(error.data && typeof error.data === 'object' ? error.data : {}) },
{ status: error.status }
);
}
console.error('API route error:', error);
return NextResponse.json({ detail: 'Internal server error' }, { status: 500 });
}
}
Component → api.post() → /api/... route → withAuth(token, headers) → backendFetch() → FastAPI
Page → Server Action → directBackendFetch() → FastAPI (single hop, no API route)
useState(initialData) → api.get (manual refresh) → API Route → withAuth → backendFetch → FastAPI
This application uses Strategy A (Simple Fetching) exclusively. No SWR.
Use useState for all pages — data changes only via user actions:
"use client";
import { useState, useCallback } from "react";
import api from "@/lib/fetch/client";
function DataView({ initialData }) {
const [data, setData] = useState(initialData);
const [isLoading, setIsLoading] = useState(false);
const refresh = useCallback(async () => {
setIsLoading(true);
try {
const fresh = await api.get(apiUrl);
setData(fresh);
} finally {
setIsLoading(false);
}
}, [apiUrl]);
const onUpdate = async (id: string, payload: object) => {
const updated = await api.put(`/api/items/${id}`, payload);
setData(current => ({
...current,
items: current.items.map(i => i.id === id ? updated : i),
}));
};
}
See nextjs/references/data-fetching-strategy.md for the full decision framework.
directBackendFetch() with Bearer tokenapi.get/post/put/delete with credentials: 'include'getServerCsrfToken() fetched from backend for mutationscsrfManager.getToken() for POST/PUT/PATCH/DELETEwithAuth passes forwardHeaders to handlerwithAuth checks token expiry with 30s bufferApiError class throughout