Skip to content

Next.js fetch vs tanstack-fetch ​

October 12, 2026 · Next.js · React · SSR

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 + browsertanstack-fetch + ssr-forward
Prefetch + HydrationBoundarytanstack-fetch + React Query
Cookie / auth header forwardincoming + ssr-forward
Simple static fetch in one RSCnative fetch is fine
SSE with Authorizationtanstack-fetch/sse
bash
npm install tanstack-fetch @tanstack/react-query

Mental 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:

  1. Success → return the parsed body
  2. HTTP failure → throw (so isError works)
  3. Cancel → pass Query’s signal through

Side-by-side ​

native fetch + manual cookies (glue) ​

ts
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 ​

ts
// 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() }
  },
})
tsx
// 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.

Feature matrix ​

Capabilitytanstack-fetchnative fetch helpers
SSR cookie forwardssr-forwardManual
Absolute server baseUrlBuilt-in patternManual
Query dehydrate/hydrateNative fitGlue
Typed errorsFetchErrorDIY
Refresh tokenPluginDIY
RSC + client same optionsqueryOptionsDuplicate
SSE + AuthorizationYesHard

Auth, status handlers, refresh ​

ts
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 ​

tsx
'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 ​

bash
npx tanstack-fetch migrate --from fetch --framework next --write
npx tanstack-fetch doctor --dir ./src
  1. Server api with ssr-forward + cookies()
  2. Shared queryOptions
  3. Prefetch in RSC + HydrationBoundary
  4. Client mutations on browser api
  5. Remove one-off fetch wrappers

Suggested folder layout ​

text
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.tsx

Decision tree ​

  1. Is TanStack Query your source of truth for server state?
    • Yes → start with tanstack-fetch
    • No → native fetch may stay
  2. Do you need SSE with Authorization or resume?
    • Yes → tanstack-fetch/sse + useSse
    • No → either client works for simple REST
  3. Do other frameworks share this backend client?
    • Yes → one createFetch across packages
    • No → still fine; adapters are optional

Checklist ​

  • [ ] Absolute API_URL on server
  • [ ] ssr-forward + incoming from cookies()
  • [ ] Shared queryOptions for RSC + client
  • [ ] Forward signal in queryFns
  • [ ] SSE only in client components
  • [ ] Doctor clean

Try it ​

bash
npm install tanstack-fetch @tanstack/react-query
npx tanstack-fetch doctor --dir ./src

Series ​

Same deep-dive format for every stack:

MIT Licensed · Created by Mohammad Garmabi · Not an official TanStack package