advanced
Advanced Duck Query patterns. Covers interceptors, error handling, custom Axios instances, using without Duck Gen, and real-world integration tips.
Interceptors
Loading diagram...
Since Duck Query is built on Axios, you have full access to request and response
interceptors through client.axios.
Auth token interceptor
Automatically attach an auth token to every request:
import { createDuckQueryClient } from '@gentleduck/query'
import type { ApiRoutes } from '@gentleduck/gen/nestjs'
const client = createDuckQueryClient<ApiRoutes>({
baseURL: 'http://localhost:3000',
})
client.axios.interceptors.request.use((config) => {
const token = localStorage.getItem('auth_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})Token refresh interceptor
Automatically refresh expired tokens and retry the request:
client.axios.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
const newToken = await refreshToken()
localStorage.setItem('auth_token', newToken)
originalRequest.headers.Authorization = `Bearer ${newToken}`
return client.axios(originalRequest)
}
return Promise.reject(error)
},
)Logging interceptor
Log all requests and responses for debugging:
client.axios.interceptors.request.use((config) => {
console.log(`>> ${config.method?.toUpperCase()} ${config.url}`)
return config
})
client.axios.interceptors.response.use(
(response) => {
console.log(`<< ${response.status} ${response.config.url}`)
return response
},
(error) => {
console.error(`!! ${error.response?.status} ${error.config?.url}`)
return Promise.reject(error)
},
)Error handling patterns
Using isAxiosError
import { isAxiosError } from 'axios'
async function signin(username: string, password: string) {
try {
const { data } = await client.post('/api/auth/signin', {
body: { username, password },
})
return { ok: true, data } as const
} catch (error) {
if (isAxiosError(error)) {
return {
ok: false,
status: error.response?.status ?? 500,
message: error.response?.data?.message ?? 'Unknown error',
} as const
}
throw error // re-throw non-Axios errors
}
}
const result = await signin('duck', '123456')
if (result.ok) {
console.log('Signed in:', result.data)
} else {
console.error(`Error ${result.status}: ${result.message}`)
}Typed error responses
If your server always returns errors in a consistent shape, you can type them:
type ApiError = {
state: 'error'
message: string
data: null
}
async function safePost<P extends PathsByMethod<ApiRoutes, 'POST'>>(
path: P,
req: RouteReqMethod<ApiRoutes, P, 'POST'>,
) {
try {
const { data } = await client.post(path, req)
return { ok: true, data } as const
} catch (error) {
if (isAxiosError<ApiError>(error) && error.response) {
return { ok: false, error: error.response.data } as const
}
throw error
}
}Using a custom Axios instance
You can pass an existing Axios instance instead of a config object. This is useful when you need to share the same Axios setup across multiple clients or when your Axios instance has special configuration.
import axios from 'axios'
import { createDuckQueryClient } from '@gentleduck/query'
import type { ApiRoutes } from '@gentleduck/gen/nestjs'
// Create and configure an Axios instance
const axiosInstance = axios.create({
baseURL: 'http://localhost:3000',
timeout: 15000,
withCredentials: true,
headers: {
'Content-Type': 'application/json',
'X-App-Version': '1.0.0',
},
})
// Add interceptors to the instance
axiosInstance.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${getToken()}`
return config
})
// Pass the instance to Duck Query
const client = createDuckQueryClient<ApiRoutes>(axiosInstance)
// The client uses your configured instance
const { data } = await client.get('/api/users')Using without Duck Gen
Duck Query works with any route map that follows the DuckRouteMeta shape. You don't
need Duck Gen at all.
Defining routes manually
import { createDuckQueryClient, type DuckRouteMeta } from '@gentleduck/query'
// Define your routes
type Routes = {
'/ping': {
method: 'GET'
params: never
query: never
headers: never
body: never
res: { ok: true }
}
'/echo': {
method: 'POST'
params: never
query: never
headers: never
body: { message: string }
res: { echo: string }
}
}
const client = createDuckQueryClient<Routes>({
baseURL: 'http://localhost:3000',
})
// Type-safe without any code generation
const { data } = await client.get('/ping')
// data: { ok: true }Sharing route types between server and client
If your server and client are in the same monorepo, you can define route types in a shared package:
export type Routes = {
'/api/users': {
method: 'GET'
params: never
query: { page?: number; limit?: number }
headers: never
body: never
res: { users: User[]; total: number }
}
'/api/users/:id': {
method: 'GET'
params: { id: string }
query: never
headers: never
body: never
res: User
}
}
export type User = {
id: string
name: string
email: string
}import { createDuckQueryClient } from '@gentleduck/query'
import type { Routes } from '@monorepo/shared/routes'
export const api = createDuckQueryClient<Routes>({
baseURL: process.env.API_URL,
})React Query integration
Duck Query pairs well with TanStack Query (React Query):
import { useQuery, useMutation } from '@tanstack/react-query'
import { createDuckQueryClient } from '@gentleduck/query'
import type { ApiRoutes } from '@gentleduck/gen/nestjs'
const client = createDuckQueryClient<ApiRoutes>({
baseURL: 'http://localhost:3000',
})
export function useUser(id: string) {
return useQuery({
queryKey: ['user', id],
queryFn: async () => {
const { data } = await client.get('/api/users/:id', {
params: { id },
})
return data
},
})
}
export function useSignin() {
return useMutation({
mutationFn: async (body: { username: string; password: string }) => {
const { data } = await client.post('/api/auth/signin', { body })
return data
},
})
}function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useUser(userId)
if (isLoading) return <p>Loading...</p>
if (error) return <p>Error loading user</p>
return <h1>{user.name}</h1> // user is fully typed
}Multiple clients
You can create multiple clients for different APIs or environments:
import { createDuckQueryClient } from '@gentleduck/query'
import type { ApiRoutes } from '@gentleduck/gen/nestjs'
// Main API client
export const api = createDuckQueryClient<ApiRoutes>({
baseURL: 'http://localhost:3000',
withCredentials: true,
})
// Admin API client with different auth
export const adminApi = createDuckQueryClient<ApiRoutes>({
baseURL: 'http://localhost:3000',
headers: { 'X-Admin-Key': process.env.ADMIN_KEY },
})
// External service client with custom route map
type ExternalRoutes = {
'/status': { method: 'GET'; params: never; query: never; headers: never; body: never; res: { up: boolean } }
}
export const external = createDuckQueryClient<ExternalRoutes>({
baseURL: 'https://external-service.com',
})Behavior details
A few things to keep in mind:
- GET and DELETE ignore body: even if the request object has a
bodyfield, it is not sent for GET, DELETE, HEAD, and OPTIONS requests. requestdefaults to GET: if you don't passconfig.method, it defaults to'GET'.- Path type checking:
client.post()only accepts paths that support POST. Trying to POST to a GET-only route is a compile-time error. - Param encoding: path parameter values are automatically URL-encoded with
encodeURIComponent. - Slash normalization: double slashes in built URLs are normalized.
Next steps
- Duck Query overview: getting started guide.
- Client Methods: detailed method reference.
- Types: all exported types.
- Templates: complete working example.