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()
}
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()
}
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')
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
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
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,
})
For browser-side requests, configure the public API URL in .env.local:
NEXT_PUBLIC_API_URL=https://api.example.com
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
With tanstack-fetch, the request can return the parsed data directly:
return api.get<User[]>('/users')
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,
})
Let's break this down.
queryKey identifies the data.
queryKey: ['users']
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 })
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
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>
)
}
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>
)
}
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>
)
}
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>
)
}
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)
},
})
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,
})
}
}
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(),
}),
})
}
Configure the server-only URL in .env.local:
API_URL=https://api.example.com
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>
)
}
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,
})
}
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:
- Next.js renders the page on the server.
- TanStack Query prefetches the user list.
-
dehydrate(queryClient)serializes the query cache into a transferable state. -
HydrationBoundaryrestores that state in the React tree. -
UsersClientreads 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 })
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
- tanstack-fetch documentation
- React integration
- Next.js and SSR guide
- TanStack Query integration
- GitHub repository
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)