Skip to main content

← Blog

Lint Your Way Out of fetch() Sprawl

How Atlas uses a centralized HTTP client, scoped ESLint rules, and request IDs to stop fetch() sprawl without confusing transport enforcement with runtime validation.

Published
March 13, 2026
Updated
Updated July 13, 2026
Author
Daniel Mark
Reading time
11 min read

TanStack Query governs server-state behavior. A sanctioned client governs HTTP behavior. ESLint can make that boundary enforceable.

I built Atlas as a platform other teams can fork. A recurring failure mode in growing frontends is not the absence of TanStack Query hooks. It is the absence of a defined transport layer. Features call fetch() directly, parse errors differently, forget authentication headers, and produce request identifiers that cannot be connected reliably across logs and error reports.

Atlas addresses that with one HTTP module, scoped lint rules that fail CI when application code bypasses it, and request IDs established at the inbound Next.js Proxy boundary and for individual browser API calls.

GitMyABI, a smart contract developer platform from AxLabs where I contributed frontend work, reflects a different tradeoff. Its current implementation uses a shared fetcher() with hand-written Zod schemas, but does not enforce that boundary through ESLint. The two repositories address the same frontend concern with different guardrails.

The boundary TanStack Query does not provide

TanStack Query answers questions about server state: caching, staleness, refetch timing, mutation lifecycles, and optimistic updates. Atlas configures those defaults in createQueryClient(), including query-level retries through isRetryableError().

None of that standardizes the HTTP request underneath.

A direct request inside the demo feature could look like this:

1
const response = await fetch(`/api/demo/items?mode=${mode}`);
2
3
if (!response.ok) {
4
throw new Error(await response.text());
5
}
6
7
return response.json();

That works in one hook. It does not scale cleanly across twenty feature folders.

One hook throws a plain Error. Another parses an RFC 7807 response. A third forgets credentials. A fourth implements its own retry loop. Support receives a stack trace with no request identifier that can be matched against server logs.

Atlas routes the same request through the central client:

1
return apiGet<DemoItemsListResponse>(`/api/demo/items?mode=${mode}`, {
2
skipAuth: true,
3
skipRetry: mode === "error",
4
});

TanStack Query still owns when the request runs and what happens to the resulting server state. The HTTP client owns headers, error normalization, transport retries, and the request ID placed on the wire.

Make one module own HTTP semantics

Atlas splits its HTTP implementation by runtime.

In the browser, @/lib/api/client.ts exports apiRequestapiGetapiPost, and the other request helpers used by feature code.

On the server, @/lib/api/server.ts builds on fetchWithContext. That implementation can forward the request ID stored in AsyncLocalStorage. The propagation only works after withRequestContext() establishes the current context, and that wiring is limited in the current application.

The browser client provides:

  • Error normalization into ApiError data containing a code, message, optional user-facing message, and correlation ID
  • Authentication injection through setAuthTokenProvider()
  • An x-request-id header on outbound requests
  • Retries for status codes 408, 429, 502, 503, and 504, along with network failures
  • Exponential retry backoff capped at ten seconds
  • Caller-configured timeouts, with no default timeout when the option is omitted
  • JSON parsing for successful responses and structured error bodies

Some of those capabilities exist without being fully adopted. setAuthTokenProvider() is supported by the client but is not wired into the current application. Session requests use cookie-based /api/auth/* routes through useSession.ts.

The browser client also does not perform request logging. Structured outbound logging exists only in the server path through fetch-with-context.server.ts.

OpenAPI-backed calls add compile-time contract agreement without replacing the transport layer. The users feature imports generated types and calls:

1
return await api.users.list(params);

That request eventually uses the same apiGet pipeline as the demo hooks.

For runtime-configured base URLs, useApiClient() constructs full URLs from useConfig(). The demo and users examples use same-origin /api/* routes or relative contract paths, which fits the repository's BFF-style structure.

There is one important retry limitation. Transport retries do not currently distinguish between methods. A POST request that reaches the server and receives a retryable response may be replayed. Network failures during mutations carry the same ambiguity.

Until retries become method-aware, callers must use skipRetry: true for operations that are not safe to replay.

Turn the convention into a CI constraint

The relevant block in eslint.config.mjs is scoped to application code:

1
{
2
files: [
3
"src/app/**/*.{ts,tsx}",
4
"src/components/**/*.{ts,tsx}",
5
"src/features/**/*.{ts,tsx}",
6
"src/providers/**/*.{ts,tsx}",
7
],
8
ignores: [
9
"src/lib/api/**",
10
"src/lib/http/**",
11
"src/app/api/**/route.ts",
12
"src/app/monitoring/route.ts",
13
],
14
rules: {
15
"no-restricted-syntax": [
16
"error",
17
{
18
selector: "CallExpression[callee.name='fetch']",
19
message:
20
"Direct fetch() calls are not allowed. Use the central API client from @/lib/api instead.",
21
},
22
],
23
"no-restricted-globals": [
24
"error",
25
{
26
name: "fetch",
27
message:
28
"Direct fetch() calls are not allowed. Use the central API client from @/lib/api instead.",
29
},
30
],
31
},
32
}

A bare global call is blocked:

1
fetch("/api/example");

It triggers both configured rules, producing duplicate diagnostics on the same call.

Assigning the global to an alias is also caught:

1
const request = fetch;
2

no-restricted-globals reports the reference to the global fetch at the assignment. The later request(url) call is not recognized as Fetch API usage, but the alias cannot be created without a lint failure.

The syntax rule also has a false-positive case. It flags calls to any function named fetch, including a locally defined function that has nothing to do with the global Fetch API.

The current configuration does not catch property-based access such as:

1
globalThis.fetch("/api/example");
2
window.fetch("/api/example");

It can also miss imported implementations called through another name, along with any usage outside the configured file globs.

That last limitation matters. src/lib/** is not included in the files list at all. The src/lib/api/** and src/lib/http/** ignore entries therefore document the intended boundary, but they are redundant within this particular configuration block.

Authentication, OAuth, and telemetry code can still call fetch() from elsewhere under src/lib/** without triggering these rules.

CI runs pnpm lint on every pull request, so violations inside the protected application directories are merge-blocking.

The active route exemptions are more deliberate:

  • src/app/api/**/route.ts contains BFF boundaries.
  • src/app/monitoring/route.ts proxies Sentry envelopes upstream.
  • Most demo route handlers return in-memory data and do not call external services.
  • api/example/route.ts demonstrates an explicitly permitted external request.

Exempting every route handler remains a tradeoff. It avoids blocking legitimate upstream calls at an HTTP boundary, but it can also move transport sprawl into the BFF layer.

A stronger long-term rule would require route handlers to use serverApiRequest unless they are the lowest-level transport adapter.

Establish request identity at the Next.js boundary

Atlas uses proxy.ts, the Next.js 16 Proxy entrypoint, to establish a request ID for every request matched by its Proxy configuration.

A shortened version of the flow looks like this:

1
import * as Sentry from "@sentry/nextjs";
2
import { type NextRequest, NextResponse } from "next/server";
3
4
export function proxy(request: NextRequest) {
5
const { pathname } = request.nextUrl;
6
7
const existingRequestId = request.headers.get("x-request-id");
8
const requestId = existingRequestId ?? crypto.randomUUID();
9
10
Sentry.setTag("correlationId", requestId);
11
Sentry.setTag("route", pathname);
12
13
const requestHeaders = new Headers(request.headers);
14
requestHeaders.set("x-request-id", requestId);
15
16
const response = NextResponse.next({
17
request: {
18
headers: requestHeaders,
19
},
20
});
21
22
response.headers.set("x-request-id", requestId);
23
24
return response;
25
}

The request and response headers serve different purposes.

Passing requestHeaders through NextResponse.next({ request: { headers } }) makes the request ID available to downstream server code. Setting the header on response.headers exposes the same ID on the response sent to the caller.

The current implementation preserves a caller-supplied x-request-id. It does not validate the value's format or length. That is a hardening gap at a public ingress because arbitrary caller-provided values can enter logs and Sentry tags.

The complete flow differs by runtime.

Inbound Next.js request: Proxy preserves an existing request ID or generates a UUID.

Server route handlers: Demo routes read request.headers.get("x-request-id") and include it in structured error responses.

Request context: withRequestContext() can copy the current inbound ID into AsyncLocalStorage for logging, Sentry, and outbound requests. Only limited routes currently establish that context.

Server outbound calls: fetchWithContext() forwards the request ID when an AsyncLocalStorage context exists.

Browser TanStack Query calls: apiRequest() generates a fresh correlation ID for each API call. The request ID returned with the original page response is not automatically reused by later browser requests.

A user inspecting the page response in DevTools will therefore see a different ID from the one attached to a later client-side API error. Atlas provides request identity for each call, but it does not establish one correlation ID for the entire page session.

Sentry has a similar boundary. Sentry.setTag("correlationId", ...) writes to the active isolation scope during Proxy execution. Server-side error capture can attach the correlation ID from AsyncLocalStorage after withRequestContext() has established it.

There are no integration tests proving that one ID flows from Proxy through a browser mutation and into a later Sentry event. The repository demonstrates the individual pieces, not a proven end-to-end chain.

Atlas uses x-request-id as the wire-level header and correlationId inside normalized error data and Sentry tags. They represent the same concept at different layers, but the value is only shared when the relevant propagation path has been wired.

Separate transport enforcement from contract validation

Lint enforcement, generated contracts, and runtime validation solve different failures. A mature repository may use all three.

Layer

Atlas

GitMyABI

Transport boundary

ESLint blocks bare fetch() in scoped application directories

Convention favors fetcher(); no lint ban

HTTP semantics

Central apiRequest and serverApiRequest implementations

apiRequest in api-errors.ts plus a fetcher() wrapper

Compile-time contracts

openapi-typescript generates schema.ts; hooks use typed api.users.* calls

api:sync generates schema.d.ts; hook validation uses separate Zod schemas

Runtime response validation

API payloads are not parsed with Zod

fetcher() calls schema.parse(data)

Request identity

Proxy stamps matched inbound requests; the browser client generates an ID for each API call

The client generates an ID for each request

Primary failure caught

Feature code bypassing the sanctioned HTTP layer

Backend response shapes drifting at runtime

GitMyABI's api:sync command reads the backend OpenAPI contract and regenerates TypeScript types. Runtime protection comes from Zod rather than from those generated paths types.

Atlas currently emphasizes lint enforcement at the transport boundary and generated types at the compile-time contract boundary. It does not perform runtime Zod validation on API payloads.

Neither approach replaces the other.

Linting makes architectural bypass harder. OpenAPI generation catches incompatible signatures during development and CI. Zod rejects unexpected response bodies at runtime.

Roll the rule into a brownfield repository

Atlas started greenfield. I would introduce the same constraint gradually in an existing repository.

  1. Land the client first. Migrate one feature folder and prove its error handling under production-like conditions.
  2. Add the rule as a warning. Apply it only to the migrated feature paths.
  3. Expand the protected globs. Add src/appsrc/providers, and selected src/lib paths as their callers move onto the client.
  4. Flip the rule to an error. Once the targeted directories are clean, let the existing CI lint job make violations merge-blocking.
  5. Document narrow exemptions. Monitoring tunnels, OAuth exchanges, and true upstream adapters may need direct Fetch API access.
  6. Standardize route handlers. Prefer serverApiRequest when a route handler is not itself the lowest transport boundary.

Trying to migrate every hook before enabling any enforcement turns platform work into a multi-week freeze. A warning applied to progressively wider file globs lets the architecture become stricter without blocking feature delivery.

Tradeoffs and current limitations

The protected scope is incomplete. Leaving src/lib/** outside the configured file globs means real HTTP call sites remain unenforced.

The rules are imprecise. Bare global calls produce duplicate diagnostics, locally shadowed functions can produce false positives, and property-based Fetch API calls remain available.

Route-handler exemptions are broad. They allow legitimate upstream adapters, but they also permit transport logic to spread through the BFF layer.

Retries are method-agnostic. Mutations can be replayed after transient responses or ambiguous network failures.

Authentication does not use the sanctioned client consistently. The token-provider mechanism exists, but current session requests bypass it.

Request context is only partially wired. The server utilities can propagate an inbound request ID, but most routes do not establish the required AsyncLocalStorage context.

Documentation has drifted. Security documentation still refers to middleware.ts, while the runtime entrypoint is proxy.ts. The client header also overstates its exclusivity.

End-to-end correlation is not proven. Proxy IDs, browser request IDs, AsyncLocalStorage, normalized errors, and Sentry tags exist, but no integration test proves the complete chain.

What I would improve next

Each improvement maps to a gap in the current repository.

  1. Extend the ESLint file scope to selected src/lib/** paths and use narrow exemptions for transport implementations, authentication exchanges, and telemetry.
  2. Replace the overlapping generic restrictions with a more precise rule that covers property access and avoids duplicate diagnostics.
  3. Add ESLint rule tests for bare calls, global aliases, globalThis.fetch, imported implementations, and locally shadowed functions.
  4. Wire the existing authentication provider or migrate session requests to the sanctioned client.
  5. Make transport retries method-aware and honor Retry-After where applicable.
  6. Validate or replace untrusted x-request-id values at the public Proxy boundary.
  7. Apply withRequestContext() consistently so server-side Sentry events and outbound requests share the current inbound request ID.
  8. Add an integration test that asserts the correlation ID exposed by a normalized API error and the corresponding server-side capture.

Closing

The goal is not to eliminate fetch(). Fetch remains the transport implementation inside sanctioned modules and at genuine HTTP boundaries.

The goal is to name the modules that own HTTP semantics and make accidental bypasses expensive in CI.

TanStack Query handles server state. It does not define your transport contract. A central client defines that contract, and ESLint turns it from README advice into a failed build.

That is the boundary I want in place before feature teams start inventing new error shapes in every pull request.


Portrait of Daniel Mark

Written by

Daniel Mark

Senior Frontend Engineer

Daniel Mark is a senior frontend engineer and product consultant with 7+ years of experience building resilient product interfaces for Web3, SaaS, and enterprise teams. He writes about frontend architecture, design systems, performance, SEO, and the engineering decisions that turn complex systems into dependable user experiences.