Coding
PromptBeginner5 minmarkdown
Markdown Converter
Agent skill for markdown-converter
7
Client and server-side fetch utilities for Next.js applications with API route proxying to FastAPI backends.
Sign in to like and favorite skills
# Fetch Architecture Skill
Client and server-side fetch utilities for Next.js applications with API route proxying to FastAPI backends.
## When to Use [T>]his Skill
Use this skill when asked to:
- Set up fetch utilities for Next.js
- Configure client-side API calls with auth refresh
- Implement server-side data fetching
- Create API route proxies to backend services
- Handle authentication tokens across layers
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────┐
│ Browser (Client) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Client Components │ │
│ │ • fetchClient.get/post/put/delete │ │
│ │ • SWR hooks with fetcher │ │
│ └──────────────────────────┬──────────────────────────┘ │
└─────────────────────────────┼───────────────────────────────┘
│ H[T>][T>]P (cookies)
▼
┌─────────────────────────────────────────────────────────────┐
│ Next.js Server │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ API Routes (app/api/...) │ │
│ │ • withAuth() wrapper │ │
│ │ • backendGet/Post/Put/Delete helpers │ │
│ └──────────────────────────┬──────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Server Actions │ │
│ │ • serverGet/Post/Put/Delete │ │
│ │ • Forwards cookies to API routes │ │
│ └──────────────────────────┬──────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Server Components (pages) │ │
│ │ • auth() session check │ │
│ │ • Call server actions for SSR data │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────┼───────────────────────────────┘
│ H[T>][T>]P (Bearer token)
▼
┌─────────────────────────────────────────────────────────────┐
│ FastAPI Backend │
│ /api/v1/... │
└─────────────────────────────────────────────────────────────┘
```
## Directory Structure
```
lib/
├── fetch/
│ ├── index.ts # Exports
│ ├── client.ts # Client-side fetch (browser)
│ ├── server.ts # Server-side fetch (actions, routes)
│ ├── api-route-helper.ts # API route wrappers
│ ├── errors.ts # Error classes
│ └── types.ts # [T>]ypeScript types
└── auth/
├── server-auth.ts # Server authentication
└── auth-service.ts # Client auth (token refresh)
```
## Core Files
### 1. Error Classes
```typescript
// 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): string {
if (typeof data === 'string') return data;
if (typeof data === 'object' && data !== null) {
const obj = data as Record<string, unknown[T>];
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';
}
```
### 2. [T>]ype Definitions
```typescript
// lib/fetch/types.ts
export interface FetchOptions {
headers?: Record<string, string[T>];
timeout?: number;
}
export interface FetchRequestOptions extends FetchOptions {
method?: 'GE[T>]' | 'POS[T>]' | 'PU[T>]' | 'PA[T>]CH' | 'DELE[T>]E';
body?: unknown;
}
```
### 3. Client Fetch (Browser)
```typescript
// lib/fetch/client.ts
"use client";
import { AuthService } from '@/lib/auth/auth-service';
import { ApiError, extractErrorMessage } from './errors';
import type { FetchOptions, FetchRequestOptions } from './types';
const DEFAUL[T>]_[T>]IMEOU[T>] = 30000;
const MAX_RE[T>]RIES = 2;
async function clientFetch<[T>][T>](
url: string,
options: FetchRequestOptions = {},
attempt = 1,
isRetryAfterRefresh = false
): Promise<[T>][T>] {
const controller = new AbortController();
const timeoutId = set[T>]imeout(
() =[T>] controller.abort(),
options.timeout || DEFAUL[T>]_[T>]IMEOU[T>]
);
try {
const response = await fetch(url, {
method: options.method || 'GE[T>]',
headers: {
'Content-[T>]ype': 'application/json',
...options.headers,
},
body: options.body ? JSON.stringify(options.body) : undefined,
signal: controller.signal,
credentials: 'include', // Include cookies
});
const data = await response.json().catch(() =[T>] ({}));
if (!response.ok) {
// Handle 401 - try token refresh
if (response.status === 401 && !isRetryAfterRefresh) {
clear[T>]imeout(timeoutId);
const new[T>]oken = await AuthService.refreshAccess[T>]oken();
if (new[T>]oken) {
return clientFetch<[T>][T>](url, options, attempt, true);
}
window.location.href = '/login';
throw new ApiError('Session expired', 401);
}
// Retry on 429/503
if ((response.status === 429 || response.status === 503) && attempt < MAX_RE[T>]RIES) {
clear[T>]imeout(timeoutId);
await new Promise(r =[T>] set[T>]imeout(r, 1000 * attempt));
return clientFetch<[T>][T>](url, options, attempt + 1);
}
throw new ApiError(extractErrorMessage(data), response.status, data);
}
return data as [T>];
} finally {
clear[T>]imeout(timeoutId);
}
}
// Legacy wrapper (returns { data: [T>] })
export const fetchClient = {
get: async <[T>][T>](url: string, opts?: FetchOptions) =[T>] {
const data = await clientFetch<[T>][T>](url, { ...opts, method: 'GE[T>]' });
return { data };
},
post: async <[T>][T>](url: string, body?: unknown, opts?: FetchOptions) =[T>] {
const data = await clientFetch<[T>][T>](url, { ...opts, method: 'POS[T>]', body });
return { data };
},
put: async <[T>][T>](url: string, body?: unknown, opts?: FetchOptions) =[T>] {
const data = await clientFetch<[T>][T>](url, { ...opts, method: 'PU[T>]', body });
return { data };
},
delete: async <[T>][T>](url: string, opts?: FetchOptions) =[T>] {
const data = await clientFetch<[T>][T>](url, { ...opts, method: 'DELE[T>]E' });
return { data };
},
};
```
### 4. Server Fetch (Actions & Routes)
```typescript
// lib/fetch/server.ts
"use server";
import { cookies, headers } from 'next/headers';
import { ApiError, extractErrorMessage } from './errors';
import type { FetchRequestOptions } from './types';
// Server → Next.js API routes
export async function serverFetch<[T>][T>](
url: string,
options: FetchRequestOptions = {}
): Promise<[T>][T>] {
const baseUrl = process.env.NEX[T>]_PUBLIC_SI[T>]E_URL || 'http://localhost:3000';
const cookieStore = await cookies();
const cookieHeader = cookieStore.getAll().map(c =[T>] `${c.name}=${c.value}`).join('; ');
const response = await fetch(`${baseUrl}${url}`, {
method: options.method || 'GE[T>]',
headers: {
'Content-[T>]ype': 'application/json',
...(cookieHeader && { Cookie: cookieHeader }),
...options.headers,
},
body: options.body ? JSON.stringify(options.body) : undefined,
});
const data = await response.json().catch(() =[T>] ({}));
if (!response.ok) {
throw new ApiError(extractErrorMessage(data), response.status, data);
}
return data as [T>];
}
// API routes → FastAPI backend
export async function backendFetch<[T>][T>](
url: string,
token: string,
options: FetchRequestOptions = {}
): Promise<[T>][T>] {
const baseUrl = process.env.NEX[T>]_PUBLIC_BACKEND_API_URL || 'http://localhost:8000';
const response = await fetch(`${baseUrl}${url}`, {
method: options.method || 'GE[T>]',
headers: {
'Content-[T>]ype': 'application/json',
'Authorization': `Bearer ${token}`,
...options.headers,
},
body: options.body ? JSON.stringify(options.body) : undefined,
});
const data = await response.json().catch(() =[T>] ({}));
if (!response.ok) {
throw new ApiError(extractErrorMessage(data), response.status, data);
}
return data as [T>];
}
// Convenience methods
export const serverGet = <[T>][T>](url: string) =[T>] serverFetch<[T>][T>](url, { method: 'GE[T>]' });
export const serverPost = <[T>][T>](url: string, body: unknown) =[T>] serverFetch<[T>][T>](url, { method: 'POS[T>]', body });
export const serverPut = <[T>][T>](url: string, body: unknown) =[T>] serverFetch<[T>][T>](url, { method: 'PU[T>]', body });
export const serverDelete = <[T>][T>](url: string) =[T>] serverFetch<[T>][T>](url, { method: 'DELE[T>]E' });
```
### 5. API Route Helper
```typescript
// lib/fetch/api-route-helper.ts
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth/server-auth';
import { backendFetch } from './server';
import { ApiError } from './errors';
export async function withAuth<[T>][T>](
handler: (token: string) =[T>] Promise<[T>][T>]
): Promise<NextResponse[T>] {
try {
const session = await auth();
if (!session?.access[T>]oken) {
return NextResponse.json({ detail: 'Unauthorized' }, { status: 401 });
}
const data = await handler(session.access[T>]oken);
return NextResponse.json(data);
} catch (error) {
if (error instanceof ApiError) {
return NextResponse.json({ detail: error.message }, { status: error.status });
}
return NextResponse.json({ detail: 'Internal server error' }, { status: 500 });
}
}
export const backendGet = <[T>][T>](url: string, token: string) =[T>]
backendFetch<[T>][T>](url, token, { method: 'GE[T>]' });
export const backendPost = <[T>][T>](url: string, token: string, body: unknown) =[T>]
backendFetch<[T>][T>](url, token, { method: 'POS[T>]', body });
export const backendPut = <[T>][T>](url: string, token: string, body: unknown) =[T>]
backendFetch<[T>][T>](url, token, { method: 'PU[T>]', body });
export const backendDelete = <[T>][T>](url: string, token: string) =[T>]
backendFetch<[T>][T>](url, token, { method: 'DELE[T>]E' });
```
## Request Flow
### Client-Side (Mutations)
```
Component → fetchClient → API Route → withAuth → backendFetch → FastAPI
```
### Server-Side (SSR)
```
Page → Server Action → serverFetch → API Route → withAuth → backendFetch → FastAPI
```
### SWR (Data Fetching)
```
useSWR(url, fetcher) → fetchClient.get → API Route → withAuth → backendFetch → FastAPI
```
## Key Patterns
1. **Client includes cookies** - `credentials: 'include'`
2. **Server forwards cookies** - Cookie header to API routes
3. **API routes use Bearer token** - Extract from session
4. **Auto token refresh** - On 401, try refresh once
5. **Consistent error format** - ApiError class
6. **Retry on rate limit** - 429/503 with backoff
Client and server-side fetch utilities for Next.js applications with API route proxying to FastAPI backends.
Use this skill when asked to:
┌─────────────────────────────────────────────────────────────┐ │ Browser (Client) │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Client Components │ │ │ │ • fetchClient.get/post/put/delete │ │ │ │ • SWR hooks with fetcher │ │ │ └──────────────────────────┬──────────────────────────┘ │ └─────────────────────────────┼───────────────────────────────┘ │ HTTP (cookies) ▼ ┌─────────────────────────────────────────────────────────────┐ │ Next.js Server │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ API Routes (app/api/...) │ │ │ │ • withAuth() wrapper │ │ │ │ • backendGet/Post/Put/Delete helpers │ │ │ └──────────────────────────┬──────────────────────────┘ │ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Server Actions │ │ │ │ • serverGet/Post/Put/Delete │ │ │ │ • Forwards cookies to API routes │ │ │ └──────────────────────────┬──────────────────────────┘ │ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Server Components (pages) │ │ │ │ • auth() session check │ │ │ │ • Call server actions for SSR data │ │ │ └─────────────────────────────────────────────────────┘ │ └─────────────────────────────┼───────────────────────────────┘ │ HTTP (Bearer token) ▼ ┌─────────────────────────────────────────────────────────────┐ │ FastAPI Backend │ │ /api/v1/... │ └─────────────────────────────────────────────────────────────┘
lib/ ├── fetch/ │ ├── index.ts # Exports │ ├── client.ts # Client-side fetch (browser) │ ├── server.ts # Server-side fetch (actions, routes) │ ├── api-route-helper.ts # API route wrappers │ ├── errors.ts # Error classes │ └── types.ts # TypeScript types └── auth/ ├── server-auth.ts # Server authentication └── auth-service.ts # Client auth (token refresh)
// 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): 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; } export interface FetchRequestOptions extends FetchOptions { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; body?: unknown; }
// lib/fetch/client.ts "use client"; import { AuthService } from '@/lib/auth/auth-service'; import { ApiError, extractErrorMessage } from './errors'; import type { FetchOptions, FetchRequestOptions } from './types'; const DEFAULT_TIMEOUT = 30000; const MAX_RETRIES = 2; async function clientFetch<T>( url: string, options: FetchRequestOptions = {}, attempt = 1, isRetryAfterRefresh = false ): Promise<T> { const controller = new AbortController(); const timeoutId = setTimeout( () => controller.abort(), options.timeout || DEFAULT_TIMEOUT ); try { const response = await fetch(url, { method: options.method || 'GET', headers: { 'Content-Type': 'application/json', ...options.headers, }, body: options.body ? JSON.stringify(options.body) : undefined, signal: controller.signal, credentials: 'include', // Include cookies }); const data = await response.json().catch(() => ({})); if (!response.ok) { // Handle 401 - try token refresh if (response.status === 401 && !isRetryAfterRefresh) { clearTimeout(timeoutId); const newToken = await AuthService.refreshAccessToken(); if (newToken) { return clientFetch<T>(url, options, attempt, true); } window.location.href = '/login'; throw new ApiError('Session expired', 401); } // Retry on 429/503 if ((response.status === 429 || response.status === 503) && attempt < MAX_RETRIES) { clearTimeout(timeoutId); await new Promise(r => setTimeout(r, 1000 * attempt)); return clientFetch<T>(url, options, attempt + 1); } throw new ApiError(extractErrorMessage(data), response.status, data); } return data as T; } finally { clearTimeout(timeoutId); } } // Legacy wrapper (returns { data: T }) export const fetchClient = { get: async <T>(url: string, opts?: FetchOptions) => { const data = await clientFetch<T>(url, { ...opts, method: 'GET' }); return { data }; }, post: async <T>(url: string, body?: unknown, opts?: FetchOptions) => { const data = await clientFetch<T>(url, { ...opts, method: 'POST', body }); return { data }; }, put: async <T>(url: string, body?: unknown, opts?: FetchOptions) => { const data = await clientFetch<T>(url, { ...opts, method: 'PUT', body }); return { data }; }, delete: async <T>(url: string, opts?: FetchOptions) => { const data = await clientFetch<T>(url, { ...opts, method: 'DELETE' }); return { data }; }, };
// lib/fetch/server.ts "use server"; import { cookies, headers } from 'next/headers'; import { ApiError, extractErrorMessage } from './errors'; import type { FetchRequestOptions } from './types'; // Server → Next.js API routes export async function serverFetch<T>( url: string, options: FetchRequestOptions = {} ): Promise<T> { const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'; const cookieStore = await cookies(); const cookieHeader = cookieStore.getAll().map(c => `${c.name}=${c.value}`).join('; '); const response = await fetch(`${baseUrl}${url}`, { method: options.method || 'GET', headers: { 'Content-Type': 'application/json', ...(cookieHeader && { Cookie: cookieHeader }), ...options.headers, }, body: options.body ? JSON.stringify(options.body) : undefined, }); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new ApiError(extractErrorMessage(data), response.status, data); } return data as T; } // API routes → FastAPI backend export async function backendFetch<T>( url: string, token: string, options: FetchRequestOptions = {} ): Promise<T> { const baseUrl = process.env.NEXT_PUBLIC_BACKEND_API_URL || 'http://localhost:8000'; const response = await fetch(`${baseUrl}${url}`, { method: options.method || 'GET', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, ...options.headers, }, body: options.body ? JSON.stringify(options.body) : undefined, }); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new ApiError(extractErrorMessage(data), response.status, data); } return data as T; } // Convenience methods export const serverGet = <T>(url: string) => serverFetch<T>(url, { method: 'GET' }); export const serverPost = <T>(url: string, body: unknown) => serverFetch<T>(url, { method: 'POST', body }); export const serverPut = <T>(url: string, body: unknown) => serverFetch<T>(url, { method: 'PUT', body }); export const serverDelete = <T>(url: string) => serverFetch<T>(url, { method: 'DELETE' });
// lib/fetch/api-route-helper.ts import { NextResponse } from 'next/server'; import { auth } from '@/lib/auth/server-auth'; import { backendFetch } from './server'; import { ApiError } from './errors'; export async function withAuth<T>( handler: (token: string) => Promise<T> ): Promise<NextResponse> { try { const session = await auth(); if (!session?.accessToken) { return NextResponse.json({ detail: 'Unauthorized' }, { status: 401 }); } const data = await handler(session.accessToken); return NextResponse.json(data); } catch (error) { if (error instanceof ApiError) { return NextResponse.json({ detail: error.message }, { status: error.status }); } return NextResponse.json({ detail: 'Internal server error' }, { status: 500 }); } } export const backendGet = <T>(url: string, token: string) => backendFetch<T>(url, token, { method: 'GET' }); export const backendPost = <T>(url: string, token: string, body: unknown) => backendFetch<T>(url, token, { method: 'POST', body }); export const backendPut = <T>(url: string, token: string, body: unknown) => backendFetch<T>(url, token, { method: 'PUT', body }); export const backendDelete = <T>(url: string, token: string) => backendFetch<T>(url, token, { method: 'DELETE' });
Component → fetchClient → API Route → withAuth → backendFetch → FastAPI
Page → Server Action → serverFetch → API Route → withAuth → backendFetch → FastAPI
useSWR(url, fetcher) → fetchClient.get → API Route → withAuth → backendFetch → FastAPI
credentials: 'include'