Skip to main content
tanstack-client-api-layer API client setup with fetch wrapper, error handling, authentication headers, and React Query integration for TanStack Start/Router.
跳到安装 Skills Marketplace 发现并探索由社区构建的 Agent Skills
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
直接命令不会经过审查 Prompt;运行前请先检查来源。
npx skills add https://github.com/agusmdev/fullstack-ai-template --skill tanstack-client-api-layer命令会保持在同一行。复制前请横向滚动并检查完整内容。
想先保存到本地?可下载 SkillsMP 当前能够提供的文件。
下载 Zip 下载中... Codebase health scanner and technical debt tracker. Use when the user asks about code quality, technical debt, dead code, large files, god classes, duplicate functions, code smells, naming issues, import cycles, or coupling problems. Also use when asked for a health score, what to fix next, or to create a cleanup plan. Supports 29 languages.
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 : ,
: ,
?:
) {
(message)
. =
}
}
{
( ) {
(message)
. =
}
}
{
( ) {
(message, , data)
. =
}
}
{
( ) {
(message, , data)
. =
}
}
{
( ) {
(message, , data)
. =
}
}
{
( ) {
(message, , data)
. =
}
}
string
public
status
number
public
data
unknown
super
this
name
'ApiError'
export
class
NetworkError
extends
Error
constructor
message : string = 'Network request failed'
super
this
name
'NetworkError'
export
class
ValidationError
extends
ApiError
constructor
message : string ,
public errors : Record <string , string []>,
data ?: unknown
super
422
this
name
'ValidationError'
export
class
UnauthorizedError
extends
ApiError
constructor
message : string = 'Unauthorized' , data ?: unknown
super
401
this
name
'UnauthorizedError'
export
class
ForbiddenError
extends
ApiError
constructor
message : string = 'Forbidden' , data ?: unknown
super
403
this
name
'ForbiddenError'
export
class
NotFoundError
extends
ApiError
constructor
message : string = 'Resource not found' , data ?: unknown
super
404
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 .defaultHeaders = { ...this .defaultHeaders , ...headers }
}
setAuthToken (token : string | null ) {
if (token) {
this .defaultHeaders = {
...this .defaultHeaders ,
Authorization : `Bearer ${token} ` ,
}
} else {
const { Authorization , ...rest } = this .defaultHeaders as Record <string , string >
this .defaultHeaders = rest
}
}
private buildURL (endpoint : string , params ?: Record <string , string | number | boolean >): string {
const url = new URL (endpoint, this .baseURL )
if (params) {
Object .entries (params).forEach (([key, value] ) => {
url.searchParams .append (key, String (value))
})
}
return url.toString ()
}
private async handleResponse<T>(response : Response ): Promise <T> {
const contentType = response.headers .get ('content-type' )
const isJson = contentType?.includes ('application/json' )
let data : unknown
try {
data = isJson ? await response.json () : await response.text ()
} catch (error) {
throw new ApiError ('Failed to parse response' , response.status )
}
if (!response.ok ) {
const message =
(isJson && typeof data === 'object' && data && 'message' in data)
? String (data.message )
: response.statusText
switch (response.status ) {
case 401 :
throw new UnauthorizedError (message, data)
case 403 :
throw new ForbiddenError (message, data)
case 404 :
throw new NotFoundError (message, data)
case 422 :
const errors =
(isJson && typeof data === 'object' && data && 'errors' in data)
? (data.errors as Record <string , string []>)
: {}
throw new ValidationError (message, errors, data)
default :
throw new ApiError (message, response.status , data)
}
}
return data as T
}
private async fetchWithTimeout (
url : string ,
config : RequestConfig
): Promise <Response > {
const { timeout = 30000 , ...fetchConfig } = config
const controller = new AbortController ()
const timeoutId = setTimeout (() => controller.abort (), timeout)
try {
const response = await fetch (url, {
...fetchConfig,
signal : controller.signal ,
})
clearTimeout (timeoutId)
return response
} catch (error) {
clearTimeout (timeoutId)
if (error instanceof Error ) {
if (error.name === 'AbortError' ) {
throw new NetworkError ('Request timeout' )
}
throw new NetworkError (error.message )
}
throw new NetworkError ('Unknown network error' )
}
}
async request<T = unknown >(
endpoint : string ,
config : RequestConfig = {}
): Promise <T> {
const { params, headers, ...restConfig } = config
const url = this .buildURL (endpoint, params)
const mergedHeaders = {
...this .defaultHeaders ,
...headers,
}
const response = await this .fetchWithTimeout (url, {
...restConfig,
headers : mergedHeaders,
})
return this .handleResponse <T>(response)
}
async get<T = unknown >(
endpoint : string ,
config ?: RequestConfig
): Promise <T> {
return this .request <T>(endpoint, { ...config, method : 'GET' })
}
async post<T = unknown >(
endpoint : string ,
body ?: unknown ,
config ?: RequestConfig
): Promise <T> {
return this .request <T>(endpoint, {
...config,
method : 'POST' ,
body : JSON .stringify (body),
})
}
async put<T = unknown >(
endpoint : string ,
body ?: unknown ,
config ?: RequestConfig
): Promise <T> {
return this .request <T>(endpoint, {
...config,
method : 'PUT' ,
body : JSON .stringify (body),
})
}
async patch<T = unknown >(
endpoint : string ,
body ?: unknown ,
config ?: RequestConfig
): Promise <T> {
return this .request <T>(endpoint, {
...config,
method : 'PATCH' ,
body : JSON .stringify (body),
})
}
async delete <T = unknown >(
endpoint : string ,
config ?: RequestConfig
): Promise <T> {
return this .request <T>(endpoint, { ...config, method : 'DELETE' })
}
}
export const apiClient = new ApiClient ()
export { ApiClient }
Step 4: Create Environment Configuration Add to .env or .env.local:
VITE_API_URL=http://localhost:8000
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.get <ApiResponse <User >>(`/api/users/${id} ` ),
create : (data : CreateUserDto ) =>
apiClient.post <ApiResponse <User >>('/api/users' , data),
update : (id : number , data : UpdateUserDto ) =>
apiClient.patch <ApiResponse <User >>(`/api/users/${id} ` , data),
delete : (id : number ) =>
apiClient.delete <ApiResponse <void >>(`/api/users/${id} ` ),
search : (query : string , page : number = 1 ) =>
apiClient.get <ApiResponse <User []>>('/api/users/search' , {
params : { q : 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 ({
queryKey : userKeys.lists (),
queryFn : async () => {
const response = await usersApi.getAll ()
return response.data
},
})
}
export function useUser (id : number ) {
return useQuery ({
queryKey : userKeys.detail (id),
queryFn : async () => {
const response = await usersApi.getById (id)
return response.data
},
enabled : !!id,
})
}
export function useCreateUser ( ) {
const queryClient = useQueryClient ()
return useMutation ({
mutationFn : (data : CreateUserDto ) => usersApi.create (data),
onSuccess : () => {
queryClient.invalidateQueries ({ queryKey : userKeys.lists () })
},
})
}
export function useUpdateUser ( ) {
const queryClient = useQueryClient ()
return useMutation ({
mutationFn : ({ id, data }: { id: number ; data: UpdateUserDto } ) =>
usersApi.update (id, data),
onSuccess : (response, variables ) => {
queryClient.invalidateQueries ({ queryKey : userKeys.detail (variables.id ) })
queryClient.invalidateQueries ({ queryKey : userKeys.lists () })
},
})
}
export function useDeleteUser ( ) {
const queryClient = useQueryClient ()
return useMutation ({
mutationFn : (id : number ) => usersApi.delete (id),
onSuccess : () => {
queryClient.invalidateQueries ({ queryKey : userKeys.lists () })
},
})
}
export function useSearchUsers (query : string , page : number = 1 ) {
return useQuery ({
queryKey : [...userKeys.lists (), 'search' , query, page],
queryFn : async () => {
const response = await usersApi.search (query, page)
return response.data
},
enabled : query.length > 0 ,
})
}
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 : true , error }
}
componentDidCatch (error : Error ) {
console .error ('ErrorBoundary caught:' , error)
}
render ( ) {
if (this .state .hasError && this .state .error ) {
if (this .props .fallback ) {
return this .props .fallback (this .state .error )
}
return <DefaultErrorFallback error ={this.state.error} />
}
return this .props .children
}
}
function DefaultErrorFallback ({ error }: { error: Error } ) {
if (error instanceof UnauthorizedError ) {
return (
<div className ="flex min-h-screen items-center justify-center" >
<div className ="text-center" >
<h1 className ="text-2xl font-bold" > Unauthorized</h1 >
<p className ="text-muted-foreground" > Please log in to continue</p >
</div >
</div >
)
}
if (error instanceof NetworkError ) {
return (
<div className ="flex min-h-screen items-center justify-center" >
<div className ="text-center" >
<h1 className ="text-2xl font-bold" > Network Error</h1 >
<p className ="text-muted-foreground" >
Unable to connect to the server. Please check your connection.
</p >
</div >
</div >
)
}
if (error instanceof ApiError ) {
return (
<div className ="flex min-h-screen items-center justify-center" >
<div className ="text-center" >
<h1 className ="text-2xl font-bold" > Error {error.status}</h1 >
<p className ="text-muted-foreground" > {error.message}</p >
</div >
</div >
)
}
return (
<div className ="flex min-h-screen items-center justify-center" >
<div className ="text-center" >
<h1 className ="text-2xl font-bold" > Something went wrong</h1 >
<p className ="text-muted-foreground" > {error.message}</p >
</div >
</div >
)
}
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 .values (error.errors ).flat ().join (', ' ),
variant : 'destructive' ,
})
} else {
toast ({
title : 'Failed to create user' ,
variant : 'destructive' ,
})
}
}
}
const handleDelete = async (id : number ) => {
try {
await deleteUser.mutateAsync (id)
toast ({ title : 'User deleted successfully' })
} catch (error) {
toast ({
title : 'Failed to delete user' ,
variant : 'destructive' ,
})
}
}
if (isLoading) return <div > Loading...</div >
if (error) return <div > Error: {error.message}</div >
return (
<div >
<div className ="mb-4" >
<Button onClick ={handleCreate} disabled ={createUser.isPending} >
{createUser.isPending ? 'Creating...' : 'Create User'}
</Button >
</div >
<div className ="space-y-2" >
{users?.map((user) => (
<div key ={user.id} className ="flex items-center justify-between" >
<span > {user.name} ({user.email})</span >
<Button
variant ="destructive"
size ="sm"
onClick ={() => handleDelete(user.id)}
disabled={deleteUser.isPending}
>
Delete
</Button >
</div >
))}
</div >
</div >
)
}
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 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, timestamp : Date .now () })
}
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 ().mockResolvedValue ({
ok : false ,
status : 500 ,
statusText : 'Internal Server Error' ,
json : async () => ({ message : 'Server error' }),
headers : new Headers ({ 'content-type' : 'application/json' }),
})
const client = new ApiClient ()
await expect (client.get ('/test' )).rejects .toThrow (ApiError )
})
it ('should handle network errors' , async () => {
global .fetch = vi.fn ().mockRejectedValue (new Error ('Network failed' ))
const client = new ApiClient ()
await expect (client.get ('/test' )).rejects .toThrow (NetworkError )
})
})
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
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