Next.js fetch vs tanstack-fetch
Stack
Next.js App Router · tanstack-fetch · @tanstack/react-query · ssr-forward
Next’s native fetch caches aggressively and does not give you a shared typed client across RSC, Route Handlers, and the browser. Cookie forwarding and Query hydration become one-off helpers.
tanstack-fetch + ssr-forward uses one createFetch on server and client: absolute baseUrl in SSR, relative in the browser, cookies from next/headers.
This post is the deep dive: architecture, side-by-side code, graphs, migrate path, SSE, tRPC, and when to keep the default stack client.
TL;DR
| If you need… | Prefer |
|---|---|
| Same client in RSC + browser | tanstack-fetch + ssr-forward |
Prefetch + HydrationBoundary | tanstack-fetch + React Query |
| Cookie / auth header forward | incoming + ssr-forward |
Simple static fetch in one RSC | native fetch is fine |
| SSE with Authorization | tanstack-fetch/sse |
npm install tanstack-fetch @tanstack/react-queryMental model
Server components need absolute URLs and request cookies. Client components need the same auth story after hydrate. One client with source: 'ssr' | 'browser' beats two fetch wrappers.
Contract that matters for Query:
- Success → return the parsed body
- HTTP failure → throw (so
isErrorworks) - Cancel → pass Query’s
signalthrough
Side-by-side
native fetch + manual cookies (glue)
import { cookies } from 'next/headers'
export const getUsers = async () => {
const jar = await cookies()
const res = await fetch(`${process.env.API_URL}/users`, {
headers: { cookie: jar.toString() },
cache: 'no-store',
})
if (!res.ok) throw new Error(String(res.status))
return res.json() as Promise<User[]>
}You reimplement status errors, auth, and browser parity yourself.
tanstack-fetch + ssr-forward
// lib/api.ts
import { createFetch } from 'tanstack-fetch'
import { cookies } from 'next/headers'
export const api = createFetch({
baseUrl: process.env.API_URL, // absolute on server
source: 'ssr',
plugins: ['ssr-forward', 'trace', 'retry-idempotent'],
incoming: async () => {
const jar = await cookies()
return { cookie: jar.toString() }
},
})// app/users/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
import { usersQueryOptions } from '@/queries/users'
import { UsersClient } from './users-client'
export default async function Page() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery(usersQueryOptions)
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<UsersClient />
</HydrationBoundary>
)
}Bootstrap / wiring
Browser bundle can import a second client (source: 'browser', relative baseUrl) or the same factory that branches on typeof window. ssr-forward no-ops on the client.
Example: Next.js SSR
Comparison graph: where glue accumulates
Rough relative boilerplate (lower is better) — a map of where teams spend time, not a micro-benchmark.
tanstack-fetchtypical Next fetch helpers
Feature matrix
| Capability | tanstack-fetch | native fetch helpers |
|---|---|---|
| SSR cookie forward | ssr-forward | Manual |
| Absolute server baseUrl | Built-in pattern | Manual |
| Query dehydrate/hydrate | Native fit | Glue |
| Typed errors | FetchError | DIY |
| Refresh token | Plugin | DIY |
| RSC + client same options | queryOptions | Duplicate |
| SSE + Authorization | Yes | Hard |
Auth, status handlers, refresh
import { createFetch } from 'tanstack-fetch'
import { createRefreshTokenInterceptor } from 'tanstack-fetch/plugins'
let expiresAt = 0
const api = createFetch({
baseUrl: '/api',
getToken: () => localStorage.getItem('access_token'),
})
api.use(
'refresh-token',
createRefreshTokenInterceptor({
refresh: async () => {
const body = await api.post<{ accessToken: string; expiresIn: number }>('/auth/refresh', {
interceptors: { eject: ['refresh-token', 'auth'] },
})
localStorage.setItem('access_token', body.accessToken)
expiresAt = Date.now() + body.expiresIn * 1000
},
before: { getExpiresAt: () => expiresAt, skewMs: 60_000 },
after: { enabled: true },
}),
)The plugin keeps one in-flight refresh and retries the failed call — the usual interceptor queue, without the queue code.
Mutations
'use client'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { createFetch } from 'tanstack-fetch'
const browserApi = createFetch({ baseUrl: '/api', source: 'browser' })
export const CreateUserButton = () => {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (input: CreateUserInput) => browserApi.post<User>('/users', { body: input }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
})
return <button onClick={() => mutation.mutate({ name: 'Ada' })}>Create</button>
}Live data: SSE
Browser EventSource cannot set Authorization. tanstack-fetch/sse streams over fetch.
Keep SSE in client components with tanstack-fetch/sse + useSse from tanstack-fetch/react. Do not open EventSource in RSC.
Status: connecting | connected | disconnected | error.
tRPC over the same client
Relative /api/trpc in the browser, absolute origin on the server. Wire createTRPCFetch(api) so cookies and refresh apply. See tRPC · Next.js.
Migrate
npx tanstack-fetch migrate --from fetch --framework next --write
npx tanstack-fetch doctor --dir ./src- Server
apiwithssr-forward+cookies() - Shared
queryOptions - Prefetch in RSC +
HydrationBoundary - Client mutations on browser api
- Remove one-off
fetchwrappers
Suggested folder layout
src/
lib/api.ts # SSR createFetch
lib/api.browser.ts # optional browser client
queries/users.ts
app/users/page.tsx # prefetch + HydrationBoundary
app/users/users-client.tsxDecision tree
- Is TanStack Query your source of truth for server state?
- Yes → start with
tanstack-fetch - No → native fetch may stay
- Yes → start with
- Do you need SSE with Authorization or resume?
- Yes →
tanstack-fetch/sse+useSse - No → either client works for simple REST
- Yes →
- Do other frameworks share this backend client?
- Yes → one
createFetchacross packages - No → still fine; adapters are optional
- Yes → one
Checklist
- [ ] Absolute
API_URLon server - [ ]
ssr-forward+incomingfromcookies() - [ ] Shared
queryOptionsfor RSC + client - [ ] Forward
signalin queryFns - [ ] SSE only in client components
- [ ] Doctor clean
Try it
npm install tanstack-fetch @tanstack/react-query
npx tanstack-fetch doctor --dir ./srcRelated
Series
Same deep-dive format for every stack:
