Why I Stopped Using Axios in Next.js App Router
Axios did not break. The platform changed—and my wrapper started fighting the framework.

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
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.
Axios equivalent exists (
For user-specific or mutation-sensitive routes, I reach for
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.
The framework already speaks HTTP in its native dialect. Sometimes the most productive move is to stop translating.
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(laterAbortController) when you learned the pattern- Familiar instance APIs and defaults
- Less boilerplate than raw
fetchin the jQuery-to-CRA era
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
fetchduring render (with caching semantics) - Client Components still fetch, but often through hooks, transitions, and navigation that race each other
fetch anyway. That duplication is where “simple wrapper” becomes two stacks.
Native fetch became first-class
Next.js extendedfetch 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 revalidateconst 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();
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.tsfor the browser- raw
fetchin Route Handlers “because caching” - occasional
fetchin a Server Component
Cache confusion
With Axios on the client, caching is always manual: React Query, SWR, Zustand, or hope. Withfetch 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 supportfetch. 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]);
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
useEffectfetch is in flight - Strict Mode double-mount in dev (surfaces missing cleanup)
- Tab filters toggling faster than network RTT
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.tsimport { 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);}
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:| Concern | With a heavy Axios layer | With fetch aligned to Next |
|---|---|---|
| Server render cache | Reimplemented or skipped | next.revalidate, tags |
| Route Handlers | Often duplicate client | Same primitive |
| Cancellation | Sometimes forgotten | AbortController + cleanup |
| Bundle on client | Axios + adapters | Built-in |
| Mental model | Framework + client library | Framework + web standards |
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
fetchcaching is irrelevant to your architecture
Final engineering lesson
App Router did not kill Axios. It changed the default answer to: use the sameRequest / 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.


