DEV Community

Mohammad Garmabi
Mohammad Garmabi

Posted on

Your React API Layer Is a Mess. Let's Fix It with TanStack Query and tanstack-fetch

A hands-on tutorial for typed API requests, better error handling, cancellation, and server-side rendering in Next.js.

Every React application starts with a few API calls.

You write a fetch, parse some JSON, render a list, and move on.

Then the application grows.

You add authentication. Different endpoints return different errors. Some requests need cancellation. Others need retries. Your Next.js pages fetch data on the server, while client components use TanStack Query. Suddenly, you have multiple API clients, duplicated error handling, and several different ways to send the same HTTP request.

Sound familiar?

The problem isn't that fetch is bad. It's that HTTP transport concerns tend to spread throughout an application when there isn't a clear boundary around them.

In this tutorial, we'll build a reusable API layer using tanstack-fetch, connect it to TanStack Query, and use it in a Next.js App Router application.

By the end, you'll have a practical architecture for queries, mutations, typed errors, request cancellation, and server-side data prefetching.

1. Why does a React API layer become difficult to maintain?

Let's start with a familiar implementation.

async function getUsers() {
  const response = await fetch('/api/users')

  if (!response.ok) {
    throw new Error('Failed to fetch users')
  }

  return response.json()
}
Enter fullscreen mode Exit fullscreen mode

This is perfectly reasonable for a small application.

But now imagine that every API function needs to handle authentication, different error responses, request headers, and cancellation.

You might end up with code like this:

async function getUsers(signal?: AbortSignal) {
  const token = localStorage.getItem('access_token')

  const response = await fetch('/api/users', {
    headers: {
      Authorization: `Bearer ${token}`,
    },
    signal,
  })

  if (response.status === 401) {
    // Handle an expired session.
  }

  if (response.status === 403) {
    // Handle insufficient permissions.
  }

  if (!response.ok) {
    throw new Error('Request failed')
  }

  return response.json()
}
Enter fullscreen mode Exit fullscreen mode

Then another developer writes a different implementation for creating users. Someone else adds a third implementation for uploading files.

Over time, the application loses consistency.

The solution isn't to build a massive abstraction that handles every possible situation. It's to establish a small, reusable HTTP contract.

For an application using TanStack Query, that contract should be straightforward:

  • Successful requests return the response data.
  • Failed HTTP requests throw an error.
  • Requests support cancellation through AbortSignal.
  • Authentication and transport configuration live in a shared client.

That's the approach we'll implement.

2. What is tanstack-fetch?

tanstack-fetch is an open-source TypeScript-first HTTP client built by Mohammad Garmabi.

It is an independent project, not an official TanStack package.

Its central idea is simple: provide an HTTP client that fits naturally into TanStack Query's queryFn and mutationFn contracts.

Instead of repeatedly adapting an HTTP client's response object, your query function can return the data directly.

const api = createFetch({
  baseUrl: 'https://api.example.com',
})

const users = await api.get<User[]>('/users')
Enter fullscreen mode Exit fullscreen mode

When a request fails, the client throws a FetchError. When a request receives an AbortSignal, it can honor cancellation.

The result is a cleaner boundary between HTTP transport and server-state management.

One important distinction:

tanstack-fetch handles HTTP requests. TanStack Query handles server state.

TanStack Query still manages caching, stale data, retries, background refetching, and query lifecycle. You don't need to replace any of those features.

3. Create the project

Let's build a small user-management feature with React, TypeScript, TanStack Query, and Next.js.

Install the dependencies:

npm install tanstack-fetch @tanstack/react-query
Enter fullscreen mode Exit fullscreen mode

We'll use the Next.js App Router and organize the relevant files like this:

src/
├── app/
│   ├── layout.tsx
│   └── users/
│       ├── page.tsx
│       └── users-client.tsx
├── lib/
│   ├── api.ts
│   └── api.server.ts
└── queries/
    └── users.ts
Enter fullscreen mode Exit fullscreen mode

This structure separates three concerns:

  • lib/ contains HTTP client configuration.
  • queries/ contains reusable query definitions.
  • app/ contains server-rendered pages and interactive UI.

You don't need to follow this exact folder structure. The important part is keeping request configuration and query definitions out of individual UI components.

4. Build a reusable API client

Create src/lib/api.ts:

import { createFetch } from 'tanstack-fetch'

export interface User {
  id: string
  name: string
  email: string
}

export const api = createFetch({
  baseUrl: process.env.NEXT_PUBLIC_API_URL,
})
Enter fullscreen mode Exit fullscreen mode

For browser-side requests, configure the public API URL in .env.local:

NEXT_PUBLIC_API_URL=https://api.example.com
Enter fullscreen mode Exit fullscreen mode

The client is now the single place where you can configure common HTTP behavior.

For example, you can add authentication or status handlers later without changing every query function.

Why return data instead of an HTTP response?

With a traditional Axios request, you commonly write:

const response = await axios.get<User[]>('/users')

return response.data
Enter fullscreen mode Exit fullscreen mode

With tanstack-fetch, the request can return the parsed data directly:

return api.get<User[]>('/users')
Enter fullscreen mode Exit fullscreen mode

This matches what TanStack Query needs: a promise that resolves to the data or rejects with an error.

It's a small difference in code, but it removes repeated response-unwrapping logic from the API layer.

5. Connect the API client to TanStack Query

Now let's define our query outside the component.

Create src/queries/users.ts:

import { queryOptions } from '@tanstack/react-query'
import { api, type User } from '@/lib/api'

export const usersQueryOptions = queryOptions({
  queryKey: ['users'] as const,

  queryFn: ({ signal }) =>
    api.get<User[]>('/users', { signal }),

  staleTime: 60_000,
})
Enter fullscreen mode Exit fullscreen mode

Let's break this down.

queryKey identifies the data.

queryKey: ['users']
Enter fullscreen mode Exit fullscreen mode

TanStack Query uses this key to cache and retrieve the user list.

queryFn defines how the data is fetched.

queryFn: ({ signal }) =>
  api.get<User[]>('/users', { signal })
Enter fullscreen mode Exit fullscreen mode

TanStack Query supplies the AbortSignal. Passing it to the HTTP client allows the request to be cancelled when appropriate.

staleTime controls data freshness.

staleTime: 60_000
Enter fullscreen mode Exit fullscreen mode

The cached data is considered fresh for 60 seconds. This doesn't mean the data is deleted after a minute; it means TanStack Query can consider it stale and refetch according to its normal rules.

The benefit of extracting this definition is that both a client component and a server-side prefetch operation can reuse the same query options.

6. Render the users in a React component

Create src/app/users/users-client.tsx:

'use client'

import { useQuery } from '@tanstack/react-query'
import { usersQueryOptions } from '@/queries/users'

export function UsersClient() {
  const {
    data: users,
    isPending,
    isError,
    error,
  } = useQuery(usersQueryOptions)

  if (isPending) {
    return <p>Loading users...</p>
  }

  if (isError) {
    return <p>Failed to load users: {error.message}</p>
  }

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>
          <strong>{user.name}</strong>
          <span> — {user.email}</span>
        </li>
      ))}
    </ul>
  )
}
Enter fullscreen mode Exit fullscreen mode

The component doesn't know how the API URL is configured, where authentication is handled, or how HTTP errors are constructed.

It only knows how to render the query state.

That is a useful separation of concerns: the component focuses on presentation, the query definition focuses on server state, and the HTTP client focuses on transport.

Don't forget the QueryClientProvider

TanStack Query needs a QueryClientProvider in the React component tree.

Create src/app/providers.tsx:

'use client'

import { useState } from 'react'
import {
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'

export function Providers({
  children,
}: {
  children: React.ReactNode
}) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60_000,
          },
        },
      }),
  )

  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}
Enter fullscreen mode Exit fullscreen mode

Then register the provider in src/app/layout.tsx:

import { Providers } from './providers'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}
Enter fullscreen mode Exit fullscreen mode

Using a client-side provider this way creates a stable QueryClient for the browser application. For server-side prefetching, we'll create a separate QueryClient for each server render.

7. Handle mutations without duplicating API logic

Queries retrieve data. Mutations create or modify it.

Suppose we want to create a user.

'use client'

import {
  useMutation,
  useQueryClient,
} from '@tanstack/react-query'
import { api, type User } from '@/lib/api'

interface CreateUserInput {
  name: string
  email: string
}

export function CreateUserButton() {
  const queryClient = useQueryClient()

  const mutation = useMutation({
    mutationFn: (body: CreateUserInput) =>
      api.post<User, never, CreateUserInput>(
        '/users',
        { body },
      ),

    onSuccess: () =>
      queryClient.invalidateQueries({
        queryKey: ['users'],
      }),
  })

  return (
    <button
      disabled={mutation.isPending}
      onClick={() =>
        mutation.mutate({
          name: 'Ada Lovelace',
          email: 'ada@example.com',
        })
      }
    >
      {mutation.isPending ? 'Creating...' : 'Create user'}
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode

The example assumes the endpoint returns the created user. If your API uses a different response format, adjust the response type accordingly.

The important part is that the mutation uses the same HTTP client as the query.

After a successful request, invalidateQueries marks the user list as stale so TanStack Query can refresh it when appropriate.

You don't need a new HTTP wrapper for POST requests, and you don't need to manually manage the cache yourself.

8. Handle HTTP errors properly

A production API needs more than a generic Request failed error.

Consider these common HTTP statuses:

Status Meaning Typical application behavior
400 Bad Request Display validation or request feedback
401 Unauthorized Handle the authentication flow
403 Forbidden Display an access-denied state
404 Not Found Display a missing-resource state
429 Too Many Requests Respect rate limits and retry guidance
500 Internal Server Error Display a recoverable failure state

tanstack-fetch provides a typed FetchError and status-specific handlers.

For example:

import {
  createFetch,
  isFetchError,
} from 'tanstack-fetch'

export const api = createFetch({
  baseUrl: process.env.NEXT_PUBLIC_API_URL,

  onUnauthorized: () => {
    // Trigger your application's authentication flow.
  },

  onForbidden: ({ status }) => {
    console.warn('Access denied:', status)
  },
})
Enter fullscreen mode Exit fullscreen mode

The handlers are illustrative. Connect them to your application's actual authentication and notification mechanisms.

You can also narrow an error using isFetchError:

try {
  await api.get('/users')
} catch (error) {
  if (isFetchError(error)) {
    console.error({
      status: error.status,
      message: error.message,
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

This lets you distinguish an HTTP failure from unrelated JavaScript errors.

A security detail worth emphasizing: a 403 Forbidden response should not automatically trigger logout or token refresh. The user may be authenticated but lack permission to access a particular resource.

Keep authentication failures separate from authorization failures.

9. The Next.js challenge: server-side rendering

So far, we've built a client-side data-fetching flow.

Next.js adds another dimension: Server Components can fetch data before the browser renders the interactive UI.

You could use native fetch directly in a Server Component, and for simple cases that may be the best choice.

But what happens when you want to reuse your HTTP configuration, forward incoming cookies to an upstream API, and hydrate the same data into TanStack Query?

You need to think about the server and browser separately.

The server may need:

  • An absolute API URL.
  • Cookies from the incoming Next.js request.
  • A request-scoped query cache.
  • A dehydration step that transfers query state to the browser.

The browser, meanwhile, should not receive private environment variables or server-only authentication credentials.

Create a server-side API client

Create src/lib/api.server.ts:

import 'server-only'

import { cookies } from 'next/headers'
import { createFetch } from 'tanstack-fetch'

export async function createServerApi() {
  const cookieStore = await cookies()

  return createFetch({
    baseUrl: process.env.API_URL!,
    source: 'ssr',
    plugins: ['ssr-forward'],

    incoming: async () => ({
      cookie: cookieStore.toString(),
    }),
  })
}
Enter fullscreen mode Exit fullscreen mode

Configure the server-only URL in .env.local:

API_URL=https://api.example.com
Enter fullscreen mode Exit fullscreen mode

This is intentionally separate from NEXT_PUBLIC_API_URL.

The ssr-forward plugin forwards the relevant incoming cookie information during server-side requests. The source: 'ssr' setting identifies the server execution context.

The cookies() API is asynchronous in current Next.js versions, so we await it before creating the client.

Security note: Only forward headers your upstream API actually needs. Never expose session secrets through NEXT_PUBLIC_ environment variables, and never use a process-wide cache for user-specific authentication data.

10. Prefetch data on the server and hydrate it in the browser

Now we'll connect the server-side client to TanStack Query.

Create src/app/users/page.tsx:

import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query'

import { createServerApi } from '@/lib/api.server'
import { usersQueryOptions } from '@/queries/users'
import { UsersClient } from './users-client'

export default async function UsersPage() {
  const api = await createServerApi()
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery({
    ...usersQueryOptions,
    queryFn: ({ signal }) =>
      api.get('/users', { signal }),
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <UsersClient />
    </HydrationBoundary>
  )
}
Enter fullscreen mode Exit fullscreen mode

This demonstrates the overall flow, but there is an important architectural detail.

The shared query options currently reference the browser-side API client. The server page overrides queryFn with the server-side client, so the prefetch uses the correct credentials and URL.

For a larger application, you can make this separation more explicit by defining a query-options factory that accepts the API client:

import { queryOptions } from '@tanstack/react-query'
import type { User } from '@/lib/api'

export function usersQueryOptionsFor(api: {
  get: <T>(path: string, options?: {
    signal?: AbortSignal
  }) => Promise<T>
}) {
  return queryOptions({
    queryKey: ['users'] as const,

    queryFn: ({ signal }) =>
      api.get<User[]>('/users', { signal }),

    staleTime: 60_000,
  })
}
Enter fullscreen mode Exit fullscreen mode

This is an illustrative interface; adapt it to the actual client type and generic signatures in your project.

With a shared factory, both the browser and server can use the same query definition while injecting their own correctly configured client.

What happens during hydration?

The sequence is:

  1. Next.js renders the page on the server.
  2. TanStack Query prefetches the user list.
  3. dehydrate(queryClient) serializes the query cache into a transferable state.
  4. HydrationBoundary restores that state in the React tree.
  5. UsersClient reads the existing query rather than necessarily starting from an empty cache.

This avoids the common experience where the server renders one version of the page and the client immediately starts over with a fresh loading state.

For a complete production implementation, ensure that server and browser query keys and data types match. Create a fresh QueryClient for each server request, and verify that the API client used by the browser is configured independently from the server-only client.

For more detail, see the tanstack-fetch SSR guide and the TanStack Query SSR guide.

11. Why request cancellation matters

Let's return to the signal argument we used earlier.

queryFn: ({ signal }) =>
  api.get<User[]>('/users', { signal })
Enter fullscreen mode Exit fullscreen mode

Why bother passing it?

Imagine a search interface where the user types a query, changes the filters, or navigates away before the request finishes.

Without cancellation support, requests may continue even after their results are no longer useful.

TanStack Query provides an AbortSignal for query functions. Passing it through to the HTTP client allows the request to be cancelled when the query is cancelled.

This is especially valuable for:

  • Search-as-you-type interfaces.
  • Filterable data tables.
  • Rapid navigation between resource pages.
  • Expensive requests that are no longer needed.

The important principle is that cancellation should travel through the entire call chain. Supporting AbortSignal in a function is not enough if the HTTP client ignores it.

12. Do you actually need tanstack-fetch?

Let's be practical.

Next.js already provides native fetch, and it offers useful caching and request memoization behavior in the appropriate server-rendering contexts. TanStack Query also works with ordinary promise-based functions.

If your application has a few public API requests, adding another library may not improve anything.

tanstack-fetch becomes more useful when you want a consistent transport layer across multiple call sites or frameworks, typed HTTP errors, reusable status handlers, or authenticated SSE support.

Requirement Possible approach
A simple request inside one Server Component Native fetch
Client-side caching and background refetching TanStack Query
Shared typed HTTP client for query functions tanstack-fetch
Consistent status handling and typed HTTP errors tanstack-fetch
Server-side cookie forwarding tanstack-fetch with ssr-forward
Authenticated server-sent events tanstack-fetch/sse

These tools aren't mutually exclusive. The right choice depends on your application's requirements.

The goal isn't to replace every native Next.js API. It's to avoid repeatedly solving the same transport problems in different places.

Final thoughts

A good API layer should make application code easier to understand, not introduce another abstraction for its own sake.

Start with a shared HTTP client. Define reusable query options. Pass the AbortSignal through. Use typed errors. When you introduce SSR, keep the server and browser authentication contexts separate and hydrate your query cache deliberately.

That's the architecture this combination makes possible:

  • React handles the interface.
  • TanStack Query manages server state.
  • tanstack-fetch handles HTTP transport.
  • Next.js provides the server-rendering and routing environment.

You don't need to implement every feature on day one. Start with one endpoint, validate the approach, and add the pieces that solve real problems in your application.

Explore the project

What does your current React API layer look like? Are you using native fetch, Axios, or a shared client with TanStack Query?

Top comments (0)