𝓟𝓚

Why I Stopped Using Axios in Next.js App Router

Axios did not break. The platform changed—and my wrapper started fighting the framework.

Dark themed developer blog cover comparing Axios and native fetch in Next.js App Router
For years, Axios was the default answer when a React app needed HTTP. It still is in plenty of codebases—and that is fine. This is not “Axios bad, fetch good.” It is a note from production: after moving seriously into the Next.js App Router, I stopped reaching for Axios in new work because native fetch started matching how the framework actually wants you to move data. The shift was not ideological. It was friction. Interceptors that grew teeth, cache behavior that felt like a second framework on top of the first one, and ghost requests during navigation that updated UI with stale answers. Meanwhile Route Handlers, Server Components, and the extended fetch on the server were already speaking Request / Response fluently.

Why I originally used Axios

Axios earned its place for good reasons:
  • Interceptors for auth headers and error normalization
  • axios.CancelToken (later AbortController) when you learned the pattern
  • Familiar instance APIs and defaults
  • Less boilerplate than raw fetch in the jQuery-to-CRA era
On a client-heavy SPA, that was a reasonable abstraction. You wrapped the network once and called api.get('/users') from components. The backend was “somewhere else,” and the browser was the main runtime that mattered.

What changed with App Router

The App Router blurred the line between server and client in a useful way:
  • Route Handlers (app/api/.../route.ts) are HTTP endpoints on the same platform as your UI
  • Server Components can call fetch during render (with caching semantics)
  • Client Components still fetch, but often through hooks, transitions, and navigation that race each other
Suddenly you are not maintaining one HTTP client—you are maintaining two mental models if Axios only lives on the client while the server uses fetch anyway. That duplication is where “simple wrapper” becomes two stacks.

Native fetch became first-class

Next.js extended fetch with caching and revalidation knobs that map to how pages are built and refreshed:
TypeScript
ts
// Server Component or Route Handler — cache for 60s, then revalidate
const res = await fetch("https://api.example.com/posts", {
next: { revalidate: 60 },
});
if (!res.ok) {
throw new Error(`Upstream failed: ${res.status}`);
}
const posts = await res.json();
On the server, that is not a nice-to-have—it is part of the rendering contract. Axios can call APIs, but it does not participate in Next’s Data Cache the same way. You end up re-implementing or bypassing platform behavior with custom clients and ad hoc stores.

The friction started appearing

These are the production smells that pushed me—not benchmark charts.

Interceptors that became a second app

Global Axios interceptors are convenient until they are not. Auth refresh, error toasts, retry logic, and logging all hook the same pipeline. In App Router apps, server and client error handling diverge (you cannot toast from a Server Component). The interceptor layer grew branches: “if typeof window…” patches that are hard to reason about during incidents.

Duplicated abstractions

A pattern I kept seeing in my own projects:
  • lib/axios.ts for the browser
  • raw fetch in Route Handlers “because caching”
  • occasional fetch in a Server Component
Three entry points. Three places to update base URLs, headers, and timeout behavior. Simpler started meaning one primitive.

Cache confusion

With Axios on the client, caching is always manual: React Query, SWR, Zustand, or hope. With fetch on the server, caching is declarative—until you mix the two and wonder why a client refetch shows fresh data while a server render does not (or vice versa). The bug reports sounded like application logic; they were often layering.

Inconsistent behavior between environments

Edge runtimes, Node serverless, and the browser all support fetch. Axios generally works, but you are carrying an extra dependency whose adapters and bundling story you must trust on every deployment target. When a Route Handler runs at the edge, I prefer the same API the runtime documents. Also read: How to Change the Vercel Server Region for Next.js · Fix “Sitemap Couldn't Fetch” in Next.js

Request cancellation and AbortController

Fast navigation is normal in App Router apps. Users click away before a request finishes. Without cancellation, you get stale responses applying to new UI—classic race conditions. fetch and Axios both support AbortController today. The difference in practice was how often my team actually wired it when Axios felt “already handled” by interceptors (it was not). Client-side pattern I use now:
TypeScript
ts
useEffect(() => {
const controller = new AbortController();
async function load() {
try {
const res = await fetch("/api/tools/items", {
signal: controller.signal,
cache: "no-store",
});
if (!res.ok) return;
const data = await res.json();
setItems(data.items);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
return; // navigation/unmount — ignore
}
setError("Failed to load");
}
}
void load();
return () => controller.abort();
}, [websiteId]);
Axios equivalent exists (signal: controller.signal). The win was not syntax—it was one cancellation story shared with server code and docs I already read for Next.js.

Ghost requests and race conditions

“Ghost request” is my label for: a response arrives after the UI context changed, and state updates anyway. Typical triggers:
  • Route change while a client useEffect fetch is in flight
  • Strict Mode double-mount in dev (surfaces missing cleanup)
  • Tab filters toggling faster than network RTT
Interceptors that globally handle errors made these harder to see—you would get a toast for a request you no longer cared about. Aborting on cleanup plus ignoring AbortError made the incidents drop without a new library.

Cache behavior and revalidation

Route Handlers are a good place to keep backend secrets and normalize responses:
TypeScript
ts
// app/api/example/route.ts
import { NextResponse } from "next/server";
export async function GET() {
const upstream = await fetch("https://internal.service/data", {
headers: { Authorization: `Bearer ${process.env.SERVICE_TOKEN}` },
next: { revalidate: 30 },
});
if (!upstream.ok) {
return NextResponse.json({ error: "Upstream error" }, { status: 502 });
}
const data = await upstream.json();
return NextResponse.json(data);
}
For user-specific or mutation-sensitive routes, I reach for cache: 'no-store' (or next: { revalidate: 0 } depending on version/docs) so I do not accidentally serve another user’s cached JSON. That is the kind of control App Router expects. Fighting it with a client-only Axios layer on top was extra ceremony.

Why fewer abstractions started winning

Platform-native APIs reduce the number of concepts on call during an outage:
ConcernWith a heavy Axios layerWith fetch aligned to Next
Server render cacheReimplemented or skippednext.revalidate, tags
Route HandlersOften duplicate clientSame primitive
CancellationSometimes forgottenAbortController + cleanup
Bundle on clientAxios + adaptersBuilt-in
Mental modelFramework + client libraryFramework + web standards
Bundle size was a bonus. The real win was fewer layers between the app and the platform—a simpler mental model when something breaks at 2 a.m.

When Axios still makes sense

I would still consider Axios when:
  • The team already standardized on it and the patterns are stable
  • You need mature interceptor ergonomics across a large Create React App–style client with little server rendering
  • You rely on Axios-specific conveniences you do not want to recreate (certain upload helpers, legacy adapters)
  • You are not on App Router, or server fetch caching is irrelevant to your architecture
Axios is not deprecated in my mind. It is optional in a stack that already centers web standards and Next’s data model.

Final engineering lesson

App Router did not kill Axios. It changed the default answer to: use the same Request / Response primitives everywhere you can, let Next own caching on the server, and keep client networking boring—abortable, explicit, and close to the route that triggered it. If you are starting a new App Router project, try fetch first in Route Handlers and Server Components before adding a global HTTP client. If friction stays low, you may never need the second abstraction. If friction rises, add tooling deliberately—not by habit from 2018. More notes like this live on the blog homepage.

FAQ

Practical questions about fetch vs Axios in Next.js

Is Axios bad for Next.js?

No. Axios is a solid HTTP client. The question is whether it adds value on top of what App Router and native fetch already provide for your architecture—especially server caching and shared Request/Response patterns.

Does fetch support request cancellation?

Yes. Pass an AbortSignal from AbortController to fetch({ signal }). Abort on useEffect cleanup or when a new request supersedes an old one. Ignore AbortError in catch blocks so cancelled requests do not look like failures.

Why does Next.js recommend native fetch?

Next can extend fetch with caching and revalidation that tie into rendering and the Data Cache. That integration is first-class on the server. Using fetch keeps server data loading aligned with framework behavior.

Does fetch improve performance?

Not automatically. It can reduce client bundle size and avoid duplicate HTTP stacks. The bigger win is often correct caching and fewer race bugs, not raw milliseconds on a single call.

What about Axios interceptors?

Interceptors are powerful but tend to centralize behavior that may need to differ between server and client in App Router apps. With fetch, I prefer small shared helpers (auth headers, JSON parse, error mapping) called explicitly where needed.

Does fetch work better with App Router?

For server-centric data loading and Route Handlers, usually yes—because you are using the same API the framework documents. For a client-only island, Axios and fetch are closer in tradeoffs.

Can Axios still be useful?

Absolutely—especially in existing codebases, teams with strong Axios conventions, or apps without much server rendering. Migration is not mandatory.

Does fetch reduce bundle size?

Typically on the client, yes—one less dependency. Treat that as a bonus, not the reason to migrate a large app.

The framework already speaks HTTP in its native dialect. Sometimes the most productive move is to stop translating.

Related posts