The Bookshelf API of The Bookshelf API already validates input, enforces roles and versions its routes. Next.js 10,514 does not replace it; it becomes its best client. Give the app one module that knows the base URL and the error contract, and no page writes a raw fetch again.
import "server-only";
const API = process.env.BOOKSHELF_API ?? "http://localhost:4310/api/v1";
export async function bookshelf(path, init) {
const headers = { accept: "application/json", ...init?.headers };
const res = await fetch(`${API}${path}`, { ...init, headers });
if (res.status === 404) return null; // an absence, not an error
if (!res.ok) throw new Error(`Bookshelf ${path} -> ${res.status}`);
return (await res.json()).data; // unwrap { data, page, links }
}
export const getBook = (id) => bookshelf(`/books/${id}`);
export const listBooks = (limit = 20) => bookshelf(`/books?limit=${limit}`);Four decisions are worth copying. import "server-only" (Server-Only Code) fails the build if a Client Component ever imports this file, keeping BOOKSHELF_API and any token out of the browser bundle — note the missing NEXT_PUBLIC_ prefix, without which the variable is an empty string on the client anyway. Unwrapping body.data puts the API's envelope in one place. A 404 becomes null rather than a throw, so a page can call notFound() and get the framework's 404 page instead of its error boundary. Every other non-2xx answer throws into error.js.
One rule saves hours: never fetch your own app's Route Handlers from a Server Component. await fetch("http://localhost:3000/api/books") makes the server call itself over HTTP, paying for a socket and a second render to get data it could have loaded directly. Import the function instead. The Express 24,430 API is different — a separate process, so HTTP is the only way in.
| Symptom | Cause |
|---|---|
| ECONNREFUSED in next build | API down while pages prerender |
| Data frozen at build time | uncached fetch in a static route |
| Token undefined in the browser | server variable read on the client |