Skip to main content
Search...

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:

packages/shared/routes.ts
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
}
apps/client/api.ts
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):

hooks/useUser.ts
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
    },
  })
}
components/UserProfile.tsx
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 body field, it is not sent for GET, DELETE, HEAD, and OPTIONS requests.
  • request defaults to GET: if you don't pass config.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