Skip to content

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

typescript
// 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 /login

401 Handling

When the API returns a 401 Unauthorized response, the client clears the stored token and redirects to the login page:

typescript
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:

typescript
// 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

tsx
"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

tsx
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

tsx
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

tsx
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

tsx
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:

tsx
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:

tsx
// 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:

tsx
// 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:

tsx
// 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:

tsx
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:

typescript
// 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:

typescript
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:

ParameterTypeDefaultDescription
pagenumber1Page number
per_pagenumber20Items per page
sort_bystringcreated_atColumn to sort by
sort_order"asc" | "desc"descSort direction
searchstring--Full-text search query
localestring--Language code for translated content
typescript
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

Released under the MIT License.