advanced
Advanced Duck Query patterns. Covers interceptors, error handling, custom Axios instances, using without Duck Gen, and real-world integration tips.
Interceptors
Duck Query is built on Axios, so request and response interceptors are available
through client.axios.
Auth token interceptor
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
})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
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)
},
)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 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)
},
)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}`)
}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 the server uses a consistent error shape, type it:
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
}
}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
Pass an existing Axios instance instead of a config object. Useful for sharing one instance across clients or for setups that need 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')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 takes any route map that follows the DuckRouteMeta shape. Duck Gen is not
required.
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 }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
In a monorepo, put 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
}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,
})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
},
})
}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
}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
Create separate clients per API or environment:
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',
})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
- GET and DELETE drop the body: a
bodyfield on the request is ignored for GET, DELETE, HEAD, and OPTIONS. requestdefaults to GET: noconfig.methodmeans'GET'.- Path type checking:
client.post()only accepts POST-capable paths. Posting to a GET-only route is a compile-time error. - Param encoding: path params run through
encodeURIComponent. - Slash normalization: double slashes in built URLs collapse to one.
Next steps
- Duck Query overview: getting started guide.
- Client Methods: detailed method reference.
- Types: all exported types.
- Templates: complete working example.