@plinth-dev/api-client — server-only typed fetch wrapper
Package: @plinth-dev/api-client
Responsibility
Section titled “Responsibility”The HTTP client every Next.js Server Component and server action uses to call backend APIs. Maintains a registry of named APIs (each with its own base URL, auth, retry policy), returns a typed ApiResponse<T> where success is the only flag the caller branches on, and never throws on HTTP errors.
API surface
Section titled “API surface”import "server-only";
export interface ApiResponse<T> { data: T | null; success: boolean; error: ApiError | null; meta: { status: number; traceId?: string; requestId?: string; };}
export interface ApiError { status: number; code: string; // matches sdk-go/errors Code: "not_found" | "validation" | ... message: string; fields?: Record<string, string>; // validation only}
export interface ApiConfig { baseUrl: string; defaultHeaders?: Record<string, string>; // Adds Authorization header per request. Called with the request context; // typically reads the JWT from the Next.js cookies/session. authHeader?: () => Promise<string | null>; timeoutMs?: number; // default 30_000 retry?: { count: number; // default 2 retries backoffMs: number; // initial delay; doubles each retry onStatuses?: number[]; // default [502, 503, 504, 429] };}
export function register(name: string, config: ApiConfig): void;
export interface ApiClient { get<T>(path: string, init?: RequestInit): Promise<ApiResponse<T>>; post<T>(path: string, body?: unknown, init?: RequestInit): Promise<ApiResponse<T>>; put<T>(path: string, body?: unknown, init?: RequestInit): Promise<ApiResponse<T>>; patch<T>(path: string, body?: unknown, init?: RequestInit): Promise<ApiResponse<T>>; delete<T>(path: string, init?: RequestInit): Promise<ApiResponse<T>>;}
// api(name) is the entry point. Throws synchronously if name is unregistered// (this is a programmer error; surface it loudly).export function api(name: string): ApiClient;
// Convenience for server-side data fetching with React's `cache` for request// deduplication within a render.export function cachedGet<T>( apiName: string, path: string, init?: RequestInit,): Promise<ApiResponse<T>>;Behaviour
Section titled “Behaviour”server-onlyenforcement. Importing from a"use client"module is a build error. The auth header reader and timeout management are server-side concerns.- Never throws on HTTP errors. A 404, 500, network failure, timeout — all return
{ success: false, error: {...}, data: null, meta: {...} }. The caller writes one branch. - Auto-parses RFC 7807 problem+json. When the response Content-Type is
application/problem+json(the shapesdk-go/errors’s middleware produces),error.code,error.message,error.fieldsare populated from the body. Other error responses get{ code: "unknown", message: <body text> }. - Retries on 5xx + 429 with exponential backoff. Default: 2 retries, 100ms initial, doubling. POST/PUT/PATCH/DELETE retry only when the request is idempotent — controlled via the
Idempotency-Keyrequest header (caller’s choice; we don’t generate keys for them). - Abort propagation. If
init.signalis provided, it cascades through the retry chain. Server-component cancellation (Next.js’s request abort) thus actually cancels in-flight retries. - Trace propagation. The current OTel span ID is injected as
traceparentheader. Backend handlers (usingsdk-go/otel) pick it up and the trace is unbroken end-to-end. - Deduplication via React’s
cache.cachedGetwrapsapi(name).get(...)with React’s per-render cache so the same request issued from multiple Server Components in one render hits the network once.
// app/api-clients.ts — registered once at module initimport { register } from "@plinth-dev/api-client";import { cookies } from "next/headers";
register("items-api", { baseUrl: process.env.ITEMS_API_URL!, authHeader: async () => { const session = (await cookies()).get("session")?.value; return session ? `Bearer ${session}` : null; }, timeoutMs: 10_000,});// app/(module)/items/[id]/page.tsximport { api } from "@plinth-dev/api-client";
export default async function Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const res = await api("items-api").get<Item>(`/items/${id}`);
if (!res.success) { if (res.error!.code === "not_found") notFound(); throw new Error(res.error!.message); // surfaces in error.tsx }
return <ItemView item={res.data!} />;}Why this shape
Section titled “Why this shape”- Never throws. Throwing on HTTP errors is the JS-fetch trap that produces try/catch +
if (!res.ok)boilerplate everywhere. One return shape, one branch, full stop. - Named registry. Centralizes config (base URL, auth, timeouts) so the call site is just
api("foo").get(...). Otherwise every call site reconstructs config or imports a singleton, both fragile. - Auto-parses problem+json. The Plinth backend produces this shape; the client speaks the same dialect natively. No per-call response shaping.
server-onlyboundary. Auth header reading usesnext/headerscookies — only valid server-side. Marking the module enforces the boundary at build time.- Idempotency-Key as opt-in. Auto-generating idempotency keys feels nice but quietly converts non-idempotent operations into possibly-double-applied. Caller decides.
Boundaries
Section titled “Boundaries”- Does not run in the browser. That’s TanStack Query’s job (or
fetchdirectly). Server Components → this client; client components → TanStack Query. - Does not transform request/response bodies beyond JSON. No camelCase ↔ snake_case mapping, no Date hydration. Modules use Zod parsers explicitly.
- Does not cache responses across requests. Use Next.js’s
unstable_cacheor React’scachefor that. This client is request-scoped. - Does not refresh expired auth tokens. The
authHeadercallback is called on each request; if it returns an expired token, the API responds 401 and the client surfaces it. Token refresh is the auth layer’s job.
Alternatives considered
Section titled “Alternatives considered”| Alternative | Why rejected |
|---|---|
axios directly | Throws on HTTP errors by default; works around it via interceptors that everyone configures slightly differently. The wrapper produces consistency. |
Generic httpClient.fetch(...) returning the raw Response | Pushes parsing, retry, error-shape concerns to every caller. Defeats the point. |
| OpenAPI codegen (typed clients per endpoint) | Couples client to spec changes; adds a build step. The <T> generic with hand-written types is enough at our scale and lets us iterate quickly. |
| Auto-generate idempotency keys (UUID per non-GET) | Silently converts intent; if a POST fails after server processed it, retry would NOT trigger because the server returned a duplicate-key response — which is a different correctness issue. Opt-in is right. |
Cross-references
Section titled “Cross-references”- Backend pairs with
sdk-go/errors’sHTTPMiddleware— the problem+json shape this client parses. sdk-go/paginate’sPage[T]is a common response type; callers doapi(...).get<Page<Item>>(...).- For client-side queries (in
"use client"components), use TanStack Query directly —@plinth-dev/api-clientdoesn’t try to be both. @plinth-dev/formsserver actions internally use this client to call the backend; that’s the integration point.