Data Fetching
FORGE uses a typed API client built on the Fetch API for all backend communication, combined with React's built-in state management for data loading. The API client handles JWT authentication, automatic token refresh, error responses, 401 redirects, and file uploads through a clean, type-safe interface.
API Client
The API client is a singleton class (lib/api.ts) that wraps the Fetch API with authentication, error handling, and response parsing. Every API call in both the admin and web applications flows through this client.
Architecture
┌──────────────────┐ ┌──────────────────┐ ┌───────────────┐
│ Component │────▶│ API Client │────▶│ Backend API │
│ │ │ │ │ │
│ api.get() │ │ + JWT injection │ │ /api/v1/... │
│ api.post() │ │ + JSON parsing │ │ │
│ api.put() │ │ + Error mapping │ │ │
│ api.delete() │ │ + 401 redirect │ │ │
│ api.upload() │ │ + Query params │ │ │
└──────────────────┘ └──────────────────┘ └───────────────┘Setup
// lib/api.ts
const API_URL = process.env.NEXT_PUBLIC_API_URL || "https://api.myapp.test";
const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || "v1";
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
private getAuthToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("access_token");
}
private buildUrl(
endpoint: string,
params?: Record<string, string | number | boolean | undefined>
): string {
const url = new URL(
`${this.baseUrl}/api/${API_VERSION}${endpoint}`
);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
url.searchParams.append(key, String(value));
}
});
}
return url.toString();
}
private async request<T>(
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
endpoint: string,
data?: unknown,
options: RequestOptions = {}
): Promise<T> {
const url = this.buildUrl(endpoint, options.params);
const token = this.getAuthToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
...options.headers,
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const config: RequestInit = {
method,
headers,
credentials: "include",
signal: options.signal,
};
if (data && method !== "GET") {
config.body = JSON.stringify(data);
}
const response = await fetch(url, config);
return this.handleResponse<T>(response);
}
// HTTP method shortcuts
async get<T>(endpoint: string, options?: RequestOptions): Promise<T> {
return this.request<T>("GET", endpoint, undefined, options);
}
async post<T>(endpoint: string, data?: unknown, options?: RequestOptions): Promise<T> {
return this.request<T>("POST", endpoint, data, options);
}
async put<T>(endpoint: string, data?: unknown, options?: RequestOptions): Promise<T> {
return this.request<T>("PUT", endpoint, data, options);
}
async patch<T>(endpoint: string, data?: unknown, options?: RequestOptions): Promise<T> {
return this.request<T>("PATCH", endpoint, data, options);
}
async delete<T>(endpoint: string, options?: RequestOptions): Promise<T> {
return this.request<T>("DELETE", endpoint, undefined, options);
}
}
// Singleton export
export const api = new ApiClient(API_URL);JWT Token Management
The API client manages two tokens: an access token (short-lived, stored in localStorage) and a refresh token (longer-lived, stored as an HTTP-only cookie by the backend).
Token Flow
Login
│
▼
POST /auth/login
│
├── access_token → localStorage
└── refresh_token → HTTP-only cookie (set by backend)
API Request
│
▼
Authorization: Bearer {access_token}
│
├── 200 OK → Return data
└── 401 Unauthorized
│
▼
POST /auth/refresh
│
├── New access_token → localStorage
│ Retry original request
└── 401 → Clear tokens, redirect to /login401 Handling
When the API returns a 401 Unauthorized response, the client clears the stored token and redirects to the login page:
private async handleResponse<T>(response: Response): Promise<T> {
if (response.status === 401) {
// Clear stored token
if (typeof window !== "undefined") {
localStorage.removeItem("access_token");
// Redirect to login
window.location.href = "/login";
}
throw new ApiError("Session expired", 401);
}
if (!response.ok) {
const error = await response.json().catch(() => ({
message: "An error occurred",
}));
throw new ApiError(error.message, response.status, error.errors);
}
// 204 No Content
if (response.status === 204) {
return {} as T;
}
return response.json();
}WARNING
The API client automatically handles 401 responses by clearing the stored token and redirecting to /login. This ensures expired sessions are handled gracefully across the application.
Response Types
The API returns consistent response shapes defined in lib/types.ts:
// Success response wrapper
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
// Paginated list response
interface PaginatedResponse<T> {
data: T[];
meta: {
current_page: number;
per_page: number;
total: number;
total_pages: number;
};
}
// Error response
interface ApiError {
code: string;
message: string;
details?: Record<string, string[]>;
}Data Loading Patterns
FORGE uses React's built-in useState and useEffect for data fetching, combined with the typed API client.
Fetching a List
"use client";
import * as React from "react";
import { toast } from "sonner";
import { api, getErrorMessage } from "@/lib/api";
import type { User } from "@/lib/types";
export default function UsersPage() {
const [users, setUsers] = React.useState<User[]>([]);
const [isLoading, setIsLoading] = React.useState(true);
const fetchUsers = React.useCallback(async () => {
setIsLoading(true);
try {
const response = await api.get<{
data: { data: User[]; meta: unknown };
}>("/admin/users");
setUsers(response.data.data);
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setIsLoading(false);
}
}, []);
React.useEffect(() => {
fetchUsers();
}, [fetchUsers]);
if (isLoading) return <LoadingSkeleton />;
return <DataTable columns={columns} data={users} />;
}Fetching a Single Resource
const [user, setUser] = React.useState<User | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
React.useEffect(() => {
const fetchUser = async () => {
try {
const response = await api.get<{ data: User }>(
`/admin/users/${id}`
);
setUser(response.data);
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setIsLoading(false);
}
};
fetchUser();
}, [id]);Creating a Resource
const handleCreate = async (data: UserCreateFormData) => {
setIsSubmitting(true);
try {
await api.post("/admin/users", data);
toast.success("User created successfully");
router.push("/users");
} catch (error) {
if (isApiError(error) && error.errors) {
// Map server validation errors to form fields
Object.entries(error.errors).forEach(([field, messages]) => {
form.setError(field as keyof UserCreateFormData, {
message: messages[0],
});
});
} else {
toast.error(getErrorMessage(error));
}
} finally {
setIsSubmitting(false);
}
};Updating a Resource
const handleUpdate = async (data: UserUpdateFormData) => {
setIsSubmitting(true);
try {
await api.put(`/admin/users/${id}`, data);
toast.success("User updated successfully");
router.push("/users");
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setIsSubmitting(false);
}
};Deleting a Resource
const handleDelete = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
try {
await api.delete(`/admin/users/${deleteTarget.id}`);
toast.success("User deleted successfully");
setDeleteTarget(null);
fetchUsers(); // Refresh the list
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setIsDeleting(false);
}
};Pagination
For paginated endpoints, pass pagination parameters as query params:
const [page, setPage] = React.useState(1);
const [perPage] = React.useState(20);
const [meta, setMeta] = React.useState<PaginationMeta | null>(null);
const fetchUsers = React.useCallback(async () => {
setIsLoading(true);
try {
const response = await api.get<PaginatedResponse<User>>(
"/admin/users",
{
params: {
page,
per_page: perPage,
sort_by: "created_at",
sort_order: "desc",
},
}
);
setUsers(response.data);
setMeta(response.meta);
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setIsLoading(false);
}
}, [page, perPage]);The DataTable component provides built-in client-side pagination, but for large datasets, server-side pagination is recommended using the meta object returned by the API:
// Pagination controls using meta from API
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {meta.current_page} of {meta.total_pages} pages
({meta.total} total)
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= meta.total_pages}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
</div>Server-Side Data Fetching
Content pages in the web application use Next.js Server Components for data fetching. These requests bypass the API client and use fetch directly with the internal API URL:
// Server Component -- no "use client" directive
async function getContent(slug: string, locale?: string) {
const apiUrl = process.env.INTERNAL_API_URL
|| process.env.NEXT_PUBLIC_API_URL;
const res = await fetch(
`${apiUrl}/api/v1/contents/${slug}?locale=${locale}`,
{ next: { revalidate: 60 } }
);
if (!res.ok) return null;
const json = await res.json();
return json.data;
}TIP
Server-side fetches use INTERNAL_API_URL (e.g., http://api:8080) for Docker-internal communication, avoiding the reverse proxy. The next: { revalidate: 60 } option enables Incremental Static Regeneration, caching the response for 60 seconds.
File Uploads
The API client provides a dedicated upload method for file uploads that uses multipart/form-data instead of JSON:
// File upload method on the API client
async upload<T>(endpoint: string, file: File, fieldName = "file"): Promise<T> {
const url = this.buildUrl(endpoint);
const token = this.getAuthToken();
const formData = new FormData();
formData.append(fieldName, file);
const headers: Record<string, string> = {
Accept: "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(url, {
method: "POST",
headers,
credentials: "include",
body: formData,
});
return this.handleResponse<T>(response);
}Use it for avatar uploads, featured images, or any file attachment:
const handleAvatarUpload = async (file: File) => {
try {
const response = await api.upload<{
data: { url: string; id: string };
}>("/media", file, "file");
setAvatarUrl(response.data.url);
toast.success("Avatar uploaded successfully");
} catch (error) {
toast.error(getErrorMessage(error));
}
};WARNING
The upload method intentionally omits the Content-Type header, allowing the browser to set it automatically with the correct multipart/form-data boundary. Do not manually set Content-Type for file uploads.
Error Handling
The API client provides two utilities for consistent error handling:
// Type guard to check if an error is an API error
export function isApiError(error: unknown): error is ApiError {
return (
typeof error === "object" &&
error !== null &&
"message" in error &&
"status" in error
);
}
// Extract a user-friendly error message
export function getErrorMessage(error: unknown): string {
if (isApiError(error)) return error.message;
if (error instanceof Error) return error.message;
return "An unexpected error occurred";
}Use these in every catch block:
try {
await api.post("/endpoint", data);
} catch (error) {
// Show toast notification with error message
toast.error(getErrorMessage(error));
// Or handle validation errors specifically
if (isApiError(error) && error.errors) {
// error.errors is Record<string, string[]>
// e.g., { email: ["Email already exists"] }
Object.entries(error.errors).forEach(([field, messages]) => {
form.setError(field, { message: messages[0] });
});
}
}Query Parameter Conventions
The backend API accepts these standard query parameters on list endpoints:
| Parameter | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Page number |
per_page | number | 20 | Items per page |
sort_by | string | created_at | Column to sort by |
sort_order | "asc" | "desc" | desc | Sort direction |
search | string | -- | Full-text search query |
locale | string | -- | Language code for translated content |
const response = await api.get<PaginatedResponse<User>>("/admin/users", {
params: {
page: 1,
per_page: 20,
sort_by: "name",
sort_order: "asc",
search: "ahmed",
},
});What's Next
- Forms & Validation -- Using the API client with forms
- Components -- DataTable and other data display components
- Internationalization -- Loading translations from the API