Skip to main content

advanced

Advanced Duck Query patterns. Covers interceptors, error handling, custom Axios instances, using without Duck Gen, and real-world integration tips.

Interceptors

Loading diagram...

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:

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
}
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,
})
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
    },
  })
}
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
}
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

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 body field on the request is ignored for GET, DELETE, HEAD, and OPTIONS.
  • request defaults to GET: no config.method means '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