Why a proxy layer
The auth token is an httpOnly cookie named agnt_token. Browser JavaScript cannot read it. That means every call to the backend must traverse a Next.js route handler that copies the cookie into the outgoing request. The wrappers in lib/api.ts are how we make that ergonomic.
backendFetch
For public endpoints that do not require a user. Reads the base URL from NEXT_PUBLIC_API_BASE, sets JSON headers, parses the response, and throws BackendError on non-2xx with the status code attached.
export class BackendError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
const BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
export async function backendFetch<T = unknown>(
path: string,
init?: RequestInit,
): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
cache: "no-store",
});
if (!res.ok) {
const text = await res.text();
throw new BackendError(text || res.statusText, res.status);
}
return res.json() as Promise<T>;
}authBackendFetch
For authenticated endpoints. Takes the incoming NextRequest, lifts the agnt_token cookie out of it, and attaches it as a Cookie header on the forwarded call. Backend handlers read the cookie the same way they would if the request came from a browser.
import type { NextRequest } from "next/server";
export async function authBackendFetch<T = unknown>(
req: NextRequest,
path: string,
init?: RequestInit,
): Promise<T> {
const token = req.cookies.get("agnt_token")?.value;
if (!token) {
throw new BackendError("Unauthorized", 401);
}
return backendFetch<T>(path, {
...init,
headers: {
...(init?.headers ?? {}),
Cookie: `agnt_token=${token}`,
},
});
}Error handling
Every route handler follows the same shape: call, catch, preserve status. Generic 500s from the Node layer are an anti-pattern — the backend already returned a meaningful status code, and the frontend should not paper over it.
import { NextRequest, NextResponse } from "next/server";
import { authBackendFetch, BackendError } from "@/lib/api";
export async function GET(req: NextRequest) {
try {
const data = await authBackendFetch(req, "/api/me/bookings");
return NextResponse.json(data);
} catch (err) {
if (err instanceof BackendError) {
return NextResponse.json({ error: err.message }, { status: err.status });
}
throw err;
}
}Commerce uses cents
The backend stores fee amounts in cents, not dollars. Fields like fees_paid_cents and pending_fees_cents come back as integers. Every display component that shows currency should divide by 100 at the boundary, not store the divided value.
Booking source mapping
One vocabulary quirk lives at the api boundary: the backend reports the booking source for A2A bookings as "a2a" but the frontend presents it as "consumer_agent". The mapping happens in the route handler so UI components only ever see the frontend-facing label.
SWR on top
Most pages wrap backendFetch / authBackendFetch in an SWR fetcher so the client gets stale-while-revalidate caching for free. A typical hook:
import useSWR from "swr";
const fetcher = (path: string) => fetch(path).then((r) => r.json());
export function useMyBookings() {
return useSWR("/api/me/bookings", fetcher);
}