| name | tanstack-client-api-layer |
| description | API client setup with fetch wrapper, error handling, authentication headers, and React Query integration for TanStack Start/Router. |
TanStack Client API Layer
Overview
This skill covers setting up a robust API client layer for TanStack Start or TanStack Router projects. It includes a type-safe fetch wrapper, centralized error handling, authentication token management, and React Query integration.
Important: This skill works for both:
- TanStack Start (SSR full-stack)
- TanStack Router (SPA client-only)
Prerequisites
- Existing TanStack Start or TanStack Router project
- React Query installed (see
tanstack-react-query-setup skill)
- TypeScript configured with path aliases
Architecture Overview
The API client layer consists of:
- API Client - Central fetch wrapper with error handling
- Error Types - Custom error classes for different error scenarios
- Response Types - Shared TypeScript interfaces for API responses
- Interceptors - Request/response middleware for auth headers and logging
- React Query Integration - Helper functions for mutations and queries
Step 1: Create Error Types
Create src/lib/api/errors.ts:
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public data?: unknown
) {
super(message)
this.name = 'ApiError'
}
}
export class NetworkError extends Error {
constructor(message: string = 'Network request failed') {
super(message)
this.name = 'NetworkError'
}
}
export class ValidationError extends ApiError {
constructor(
message: string,
public errors: Record<string, string[]>,
data?: unknown
) {
super(message, 422, data)
this.name = 'ValidationError'
}
}
export class UnauthorizedError extends ApiError {
constructor(message: string = 'Unauthorized', data?: unknown) {
super(message, 401, data)
this.name = 'UnauthorizedError'
}
}
export class ForbiddenError extends ApiError {
constructor(message: string = 'Forbidden', data?: unknown) {
super(message, 403, data)
this.name = 'ForbiddenError'
}
}
export class NotFoundError extends ApiError {
constructor(message: string = 'Resource not found', data?: unknown) {
super(message, 404, data)
this.name = 'NotFoundError'
}
}
Step 2: Create Response Types
Create src/lib/api/types.ts:
export interface ApiResponse<T = unknown> {
data: T
message?: string
errors?: Record<string, string[]>
}
export interface PaginatedResponse<T> {
data: T[]
meta: {
current_page: number
per_page: number
total: number
last_page: number
}
}
export type ApiMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
export interface RequestConfig extends RequestInit {
params?: Record<string, string | number | boolean>
timeout?: number
}
Step 3: Create API Client
Create src/lib/api/client.ts:
import {
ApiError,
NetworkError,
ValidationError,
UnauthorizedError,
ForbiddenError,
NotFoundError,
} from './errors'
import type { RequestConfig } from './types'
class ApiClient {
private baseURL: string
private defaultHeaders: HeadersInit
constructor(baseURL: string = '') {
this.baseURL = baseURL || import.meta.env.VITE_API_URL || ''
this.defaultHeaders = {
'Content-Type': 'application/json',
}
}
setBaseURL(url: string) {
this.baseURL = url
}
setDefaultHeaders(headers: HeadersInit) {
this. = { ...., ...headers }
}
() {
(token) {
. = {
....,
: ,
}
} {
{ , ...rest } = . <, >
. = rest
}
}
(: , ?: <, | | >): {
url = (endpoint, .)
(params) {
.(params).( {
url..(key, (value))
})
}
url.()
}
handleResponse<T>(: ): <T> {
contentType = response..()
isJson = contentType?.()
:
{
data = isJson ? response.() : response.()
} (error) {
(, response.)
}
(!response.) {
message =
(isJson && data === && data && data)
? (data.)
: response.
(response.) {
:
(message, data)
:
(message, data)
:
(message, data)
:
errors =
(isJson && data === && data && data)
? (data. <, []>)
: {}
(message, errors, data)
:
(message, response., data)
}
}
data T
}
(
: ,
:
): <> {
{ timeout = , ...fetchConfig } = config
controller = ()
timeoutId = ( controller.(), timeout)
{
response = (url, {
...fetchConfig,
: controller.,
})
(timeoutId)
response
} (error) {
(timeoutId)
(error ) {
(error. === ) {
()
}
(error.)
}
()
}
}
request<T = >(
: ,
: = {}
): <T> {
{ params, headers, ...restConfig } = config
url = .(endpoint, params)
mergedHeaders = {
....,
...headers,
}
response = .(url, {
...restConfig,
: mergedHeaders,
})
.<T>(response)
}
get<T = >(
: ,
?:
): <T> {
.<T>(endpoint, { ...config, : })
}
post<T = >(
: ,
?: ,
?:
): <T> {
.<T>(endpoint, {
...config,
: ,
: .(body),
})
}
put<T = >(
: ,
?: ,
?:
): <T> {
.<T>(endpoint, {
...config,
: ,
: .(body),
})
}
patch<T = >(
: ,
?: ,
?:
): <T> {
.<T>(endpoint, {
...config,
: ,
: .(body),
})
}
<T = >(
: ,
?:
): <T> {
.<T>(endpoint, { ...config, : })
}
}
apiClient = ()
{ }
Step 4: Create Environment Configuration
Add to .env or .env.local:
VITE_API_URL=http://localhost:8000
For production:
VITE_API_URL=https://api.yourapp.com
Step 5: Create API Service Example
Create src/lib/api/services/users.ts:
import { apiClient } from '../client'
import type { ApiResponse } from '../types'
export interface User {
id: number
email: string
name: string
created_at: string
updated_at: string
}
export interface CreateUserDto {
email: string
name: string
password: string
}
export interface UpdateUserDto {
email?: string
name?: string
password?: string
}
export const usersApi = {
getAll: () =>
apiClient.get<ApiResponse<User[]>>('/api/users'),
getById: (id: number) =>
apiClient.<<>>(),
:
apiClient.<<>>(, data),
:
apiClient.<<>>(, data),
:
apiClient.<<>>(),
:
apiClient.<<[]>>(, {
: { : query, page },
}),
}
Step 6: React Query Integration
Create src/lib/api/hooks/useUsers.ts:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { usersApi } from '../services/users'
import type { CreateUserDto, UpdateUserDto } from '../services/users'
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: string) => [...userKeys.lists(), { filters }] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: number) => [...userKeys.details(), id] as const,
}
export function useUsers() {
return useQuery({
: userKeys.(),
: () => {
response = usersApi.()
response.
},
})
}
() {
({
: userKeys.(id),
: () => {
response = usersApi.(id)
response.
},
: !!id,
})
}
() {
queryClient = ()
({
: usersApi.(data),
: {
queryClient.({ : userKeys.() })
},
})
}
() {
queryClient = ()
({
:
usersApi.(id, data),
: {
queryClient.({ : userKeys.(variables.) })
queryClient.({ : userKeys.() })
},
})
}
() {
queryClient = ()
({
: usersApi.(id),
: {
queryClient.({ : userKeys.() })
},
})
}
() {
({
: [...userKeys.(), , query, page],
: () => {
response = usersApi.(query, page)
response.
},
: query. > ,
})
}
Step 7: Error Handling in Components
Create src/components/error-boundary.tsx:
import { Component, type ReactNode } from 'react'
import { ApiError, NetworkError, UnauthorizedError } from '~/lib/api/errors'
interface Props {
children: ReactNode
fallback?: (error: Error) => ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: , error }
}
() {
.(, error)
}
() {
(.. && ..) {
(..) {
..(..)
}
}
..
}
}
() {
(error ) {
(
)
}
(error ) {
(
)
}
(error ) {
(
)
}
(
)
}
Step 8: Usage in Components
Example component using the API client:
import { useUsers, useCreateUser, useDeleteUser } from '~/lib/api/hooks/useUsers'
import { Button } from '~/components/ui/button'
import { useToast } from '~/hooks/use-toast'
export default function UsersPage() {
const { data: users, isLoading, error } = useUsers()
const createUser = useCreateUser()
const deleteUser = useDeleteUser()
const { toast } = useToast()
const handleCreate = async () => {
try {
await createUser.mutateAsync({
email: 'new@example.com',
name: 'New User',
password: 'password123',
})
toast({ title: 'User created successfully' })
} catch (error) {
if (error instanceof ValidationError) {
toast({
title: 'Validation failed',
description: Object.(error.).().(),
: ,
})
} {
({
: ,
: ,
})
}
}
}
= () => {
{
deleteUser.(id)
({ : })
} (error) {
({
: ,
: ,
})
}
}
(isLoading)
(error)
(
)
}
Step 9: Authentication Integration
Create src/lib/api/auth.ts:
import { apiClient } from './client'
export function initializeAuth() {
const token = localStorage.getItem('auth_token')
if (token) {
apiClient.setAuthToken(token)
}
}
export function setAuthToken(token: string) {
localStorage.setItem('auth_token', token)
apiClient.setAuthToken(token)
}
export function clearAuthToken() {
localStorage.removeItem('auth_token')
apiClient.setAuthToken(null)
}
Call initializeAuth() in your root component or router setup:
import { useEffect } from 'react'
import { initializeAuth } from '~/lib/api/auth'
export function App() {
useEffect(() => {
initializeAuth()
}, [])
return <div>{/* Your app */}</div>
}
Project Structure After Setup
your-project/
├── src/
│ ├── lib/
│ │ └── api/
│ │ ├── client.ts # API client with fetch wrapper
│ │ ├── errors.ts # Custom error types
│ │ ├── types.ts # Shared API types
│ │ ├── auth.ts # Auth token management
│ │ ├── services/ # API service modules
│ │ │ ├── users.ts
│ │ │ └── ...
│ │ └── hooks/ # React Query hooks
│ │ ├── useUsers.ts
│ │ └── ...
│ ├── components/
│ │ └── error-boundary.tsx # Error handling component
│ └── routes/ # TanStack routes
├── .env # Environment variables
└── vite.config.ts
Advanced Features
Request Interceptors
Add logging or modify requests globally:
async request<T = unknown>(
endpoint: string,
config: RequestConfig = {}
): Promise<T> {
if (import.meta.env.DEV) {
console.log(`[API] ${config.method || 'GET'} ${endpoint}`, config)
}
const { params, headers, ...restConfig } = config
}
Response Caching
Add simple cache layer:
private cache = new Map<string, { data: unknown; timestamp: number }>()
private cacheTimeout = 5 * 60 * 1000
private getCacheKey(url: string, config: RequestConfig): string {
return `${url}:${JSON.stringify(config)}`
}
private getFromCache<T>(key: string): T | null {
const cached = this.cache.get(key)
if (cached && Date.now() - cached.timestamp < this.cacheTimeout) {
return cached.data as T
}
return null
}
private setCache(key: string, data: unknown): void {
this.cache.set(key, { data, : .() })
}
Retry Logic
Add automatic retry for failed requests:
async requestWithRetry<T>(
endpoint: string,
config: RequestConfig = {},
retries: number = 3
): Promise<T> {
try {
return await this.request<T>(endpoint, config)
} catch (error) {
if (retries > 0 && error instanceof NetworkError) {
await new Promise(resolve => setTimeout(resolve, 1000))
return this.requestWithRetry<T>(endpoint, config, retries - 1)
}
throw error
}
}
Testing
Create src/lib/api/__tests__/client.test.ts:
import { describe, it, expect, vi } from 'vitest'
import { ApiClient } from '../client'
import { ApiError, NetworkError } from '../errors'
describe('ApiClient', () => {
it('should make GET request', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: 'test' }),
headers: new Headers({ 'content-type': 'application/json' }),
})
const client = new ApiClient('https://api.example.com')
const result = await client.get('/test')
expect(result).toEqual({ data: 'test' })
})
it('should throw ApiError on 500', async () => {
global.fetch = vi.fn().({
: ,
: ,
: ,
: () => ({ : }),
: ({ : }),
})
client = ()
(client.())..()
})
(, () => {
. = vi.().( ())
client = ()
(client.())..()
})
})
Common Issues
Issue: CORS errors in development
Solution: Configure Vite proxy in vite.config.ts:
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})
Issue: Stale data after mutations
Solution: Use proper query invalidation in mutation onSuccess:
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: userKeys.lists() })
}
Issue: Type errors with generic responses
Solution: Explicitly type the response:
const response = await apiClient.get<ApiResponse<User[]>>('/api/users')
Notes
- API client is a singleton by default for shared state
- Authentication tokens are automatically added to all requests
- Errors are typed for better error handling
- Works seamlessly with React Query for caching and refetching
- Timeout defaults to 30 seconds per request
- All requests use JSON by default
Next Steps
After API client setup:
- Create service modules for each API resource
- Add React Query hooks for data fetching
- Implement authentication flows (see
tanstack-client-auth skill)
- Add optimistic updates for better UX
- Set up error boundaries for graceful error handling
Resources