Axios JSON Request & Response: GET, POST, Interceptors, and TypeScript

Last updated:

Axios automatically serializes JavaScript objects to JSON on POST/PUT and deserializes JSON responses — you never call JSON.stringify() or JSON.parse() manually. Default timeout is 0 (no timeout); set timeout: 5000 in every production instance to prevent slow servers from hanging your application indefinitely. Axios sets Content-Type: application/json automatically when the request body is an object; the native fetch() API does not — you must set the header and stringify the body yourself. This guide covers GET and POST with typed responses, creating axios instances with baseURL and headers, request/response interceptors for auth tokens, error handling with AxiosError, and transformRequest/transformResponse for custom serialization.

GET Requests: axios.get() with TypeScript Generics

axios.get(url) sends an HTTP GET request and returns a Promise that resolves to an AxiosResponse object. The parsed JSON body is at response.data — Axios calls JSON.parse() automatically on any response with a Content-Type: application/json header. The TypeScript generic axios.get<T>(url) types response.data as T at compile time without runtime validation; combine with Zod for production-grade type safety.

import axios from 'axios'

interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user'
}

interface PaginatedResponse<T> {
  data: T[]
  total: number
  page: number
  perPage: number
}

// ── Basic GET — response.data is already a parsed JS object ──────────
const response = await axios.get('https://api.example.com/users/1')
console.log(response.data)    // { id: 1, name: 'Alice', email: '...' }
console.log(response.status)  // 200
console.log(response.headers['content-type']) // 'application/json; charset=utf-8'

// ── TypeScript generic — response.data typed as User ─────────────────
const { data: user } = await axios.get<User>('/api/users/1')
// user.name, user.email, user.role are all typed

// ── Paginated list — generic with wrapper type ────────────────────────
const { data: result } = await axios.get<PaginatedResponse<User>>('/api/users', {
  params: { page: 1, perPage: 20, role: 'admin' },
})
// result.data is User[], result.total is number

// ── Query params — appended to the URL automatically ─────────────────
// GET /users?page=2&status=active&ids[]=1&ids[]=2
const { data: filtered } = await axios.get<User[]>('/api/users', {
  params: { page: 2, status: 'active', ids: [1, 2] },
})

// ── Runtime validation with Zod (TypeScript generics are compile-only) ──
import { z } from 'zod'

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  role: z.enum(['admin', 'user']),
})

const { data: rawUser } = await axios.get('/api/users/1')
const validatedUser = UserSchema.parse(rawUser) // throws ZodError if shape is wrong

// ── Per-request config options ────────────────────────────────────────
const { data } = await axios.get<User>('/api/users/1', {
  timeout: 3000,                           // override instance timeout
  headers: { 'Accept-Language': 'en-US' }, // extra headers for this request
  params: { include: 'profile' },          // query params
})

TypeScript generics on Axios methods are compile-time annotations — if the server returns a shape that differs from T, TypeScript will not catch it at runtime. This is the most common source of subtle bugs in TypeScript API clients. Always validate server responses with Zod or a similar runtime schema library in production code. The params option serializes arrays as ids[]=1&ids[]=2 by default; configure paramsSerializer on your axios instance to use comma-separated format (ids=1,2) if your API expects that style.

POST, PUT, and PATCH: Sending JSON Bodies

Pass a plain JavaScript object as the second argument to axios.post(), axios.put(), or axios.patch() — Axios automatically calls JSON.stringify() on the object and sets Content-Type: application/json. The response body is also auto-parsed: response.data contains the server's JSON response. Never call JSON.stringify() yourself when the body is an object; doing so double-encodes it (the string gets encoded again by Axios).

import axios from 'axios'

interface CreateUserPayload {
  name: string
  email: string
  role: 'admin' | 'user'
}

interface User extends CreateUserPayload {
  id: number
  createdAt: string
}

// ── POST — object serialized automatically ────────────────────────────
const { data: newUser } = await axios.post<User>('/api/users', {
  name: 'Alice',
  email: 'alice@example.com',
  role: 'user',
} satisfies CreateUserPayload)
// newUser.id is now available — typed as User

// ── PUT — replace the entire resource ────────────────────────────────
const { data: updatedUser } = await axios.put<User>('/api/users/1', {
  name: 'Alice Smith',
  email: 'alice.smith@example.com',
  role: 'admin',
})

// ── PATCH — update specific fields only ──────────────────────────────
const { data: patchedUser } = await axios.patch<User>('/api/users/1', {
  email: 'new@example.com',
})

// ── DELETE — typically no body; check status code ─────────────────────
const { status } = await axios.delete('/api/users/1')
console.log(status) // 204 No Content

// ── POST with query params AND JSON body ──────────────────────────────
const { data: searchResult } = await axios.post<User[]>(
  '/api/users/search',
  { filters: { role: 'admin', active: true } }, // body (auto-serialized)
  { params: { page: 1, perPage: 10 } },          // query string
)
// Sends: POST /api/users/search?page=1&perPage=10
// Body: {"filters":{"role":"admin","active":true}}

// ── Sending a pre-serialized JSON string (edge case) ──────────────────
// Only do this when you have already-serialized JSON (e.g., from a cache)
const preSerializedJson = '{"name":"Bob","email":"bob@example.com"}'
await axios.post('/api/users', preSerializedJson, {
  headers: { 'Content-Type': 'application/json' },
})
// Axios sends the string as-is — no double-encoding

// ── Checking response status code ─────────────────────────────────────
const createResponse = await axios.post<User>('/api/users', { name: 'Carol', email: 'carol@example.com' })
if (createResponse.status === 201) {
  console.log('User created:', createResponse.data.id)
}

The satisfies keyword (TypeScript 4.9+) on the request body lets TypeScript validate the object shape against your payload interface without widening the type — it catches extra or missing fields at the call site rather than at the function definition. For REST APIs that return 201 Created on POST, Axios resolves the promise normally (any 2xx is a success). Axios only rejects the promise for 4xx and 5xx responses by default — configure validateStatus in axios.create() to customize which status codes trigger errors.

axios.create(): Instance Configuration with baseURL and Headers

Create one axios instance per API origin with axios.create() — not one per request. An instance carries a baseURL, default timeout, and default headers that apply to every request made with it. Calling methods on the global axios object means every request must repeat the base URL and headers; instances eliminate that repetition and prevent auth tokens meant for one API from leaking into requests to another.

// lib/api.ts — create one instance per API, export it
import axios from 'axios'
import type { AxiosInstance } from 'axios'

// ── Primary API instance ───────────────────────────────────────────────
export const api: AxiosInstance = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL ?? 'https://api.example.com',
  timeout: 5000,   // ALWAYS set a timeout in production — default is 0 (no timeout)
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
    'X-App-Version': '2.1.0',
  },
})

// ── Third-party API instance — separate from your API ─────────────────
export const stripeApi = axios.create({
  baseURL: 'https://api.stripe.com/v1',
  timeout: 10000,
  headers: {
    Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
    'Content-Type': 'application/x-www-form-urlencoded',
  },
})

// ── Usage: baseURL prefix is prepended automatically ──────────────────
// api.get('/users/1')  → GET https://api.example.com/users/1
// api.post('/users', payload)  → POST https://api.example.com/users

// ── Configure validateStatus to customize which codes throw errors ─────
export const permissiveApi = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 5000,
  // Only throw on 5xx — handle 4xx responses in application code
  validateStatus: (status) => status < 500,
})

// ── Modify instance defaults after creation ────────────────────────────
api.defaults.headers.common['X-Request-Id'] = generateRequestId()
api.defaults.timeout = 8000

// ── Type-safe wrapper functions using the instance ─────────────────────
export async function getUser(id: number): Promise<User> {
  const { data } = await api.get<User>(`/users/${id}`)
  return data
}

export async function createUser(payload: CreateUserPayload): Promise<User> {
  const { data } = await api.post<User>('/users', payload)
  return data
}

// ── Testing: swap the baseURL for a mock server ────────────────────────
if (process.env.NODE_ENV === 'test') {
  api.defaults.baseURL = 'http://localhost:3001'
  api.defaults.timeout = 1000 // fail fast in tests
}

Never add per-user auth tokens to instance defaults.headers — defaults are shared across all requests and all users in a server-side context (Node.js, Next.js Route Handlers), which can leak one user's token into another user's request. Instead, add tokens in a request interceptor that reads from the current request context. The validateStatus function controls which HTTP status codes resolve vs reject the promise — the default is status >= 200 && status < 300; override it when your API uses non-standard status codes.

Request Interceptors: Attaching Auth Tokens Automatically

axios.interceptors.request.use(onFulfilled, onRejected) registers a function that runs before every outgoing request on that instance. The interceptor receives the AxiosRequestConfig object, modifies it (typically adding an Authorization header), and returns it. Request interceptors execute in reverse registration order — last registered runs first. Use interceptors to centralize token attachment instead of passing headers on every individual call.

import axios from 'axios'
import type { InternalAxiosRequestConfig } from 'axios'

const api = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 5000,
})

// ── Request interceptor: attach JWT Bearer token ──────────────────────
const requestInterceptorId = api.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    // Client-side: read from localStorage or a cookie
    const token = typeof window !== 'undefined'
      ? localStorage.getItem('access_token')
      : null

    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config // MUST return config
  },
  (error) => {
    // Interceptor setup error — rarely triggered
    return Promise.reject(error)
  }
)

// ── Remove the interceptor when no longer needed ───────────────────────
// api.interceptors.request.eject(requestInterceptorId)

// ── Request interceptor: add request ID for distributed tracing ────────
api.interceptors.request.use((config) => {
  config.headers['X-Request-Id'] = crypto.randomUUID()
  config.headers['X-Timestamp'] = Date.now().toString()
  return config
})

// ── Request interceptor: logging ──────────────────────────────────────
api.interceptors.request.use((config) => {
  if (process.env.NODE_ENV === 'development') {
    console.log(`[${config.method?.toUpperCase()}] ${config.baseURL}${config.url}`, {
      params: config.params,
      data: config.data,
    })
  }
  return config
})

// ── Multiple interceptors: execution order ────────────────────────────
// Interceptors run in REVERSE order of registration:
// Last-registered runs first (stack-like LIFO)
// So auth interceptor registered first → runs last
// Logging interceptor registered last → runs first

// ── Server-side: pass token from request context (Next.js Route Handler) ──
import { cookies } from 'next/headers'

async function createServerApiClient() {
  const cookieStore = await cookies()
  const token = cookieStore.get('session_token')?.value

  const serverApi = axios.create({
    baseURL: process.env.API_URL,
    timeout: 5000,
  })

  serverApi.interceptors.request.use((config) => {
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  })

  return serverApi
}
// Create a new instance per request on the server — never share instances with tokens

On the server side (Next.js Route Handlers, Node.js API servers), create a new axios instance per incoming request rather than attaching tokens to a shared instance. Shared instances with per-user tokens in a concurrent server environment cause token leakage between users. The InternalAxiosRequestConfig type (not AxiosRequestConfig) is the correct type for the interceptor's config parameter in Axios v1+ — it includes the headers object as a required property.

Response Interceptors: Global Error Handling and Token Refresh

axios.interceptors.response.use(onFulfilled, onRejected) registers handlers that run after every response. The onFulfilled callback receives a successful AxiosResponse; onRejected receives an AxiosError for 4xx/5xx responses and network errors. Response interceptors execute in registration order — first registered runs first. Use them for global 401 redirect, token refresh on 401, and response logging.

import axios from 'axios'
import type { AxiosError, AxiosResponse } from 'axios'

const api = axios.create({ baseURL: 'https://api.example.com', timeout: 5000 })

// ── Response interceptor: global 401 redirect ─────────────────────────
api.interceptors.response.use(
  (response: AxiosResponse) => response, // pass through successful responses
  async (error: AxiosError) => {
    if (error.response?.status === 401) {
      // Token expired or invalid — redirect to login
      if (typeof window !== 'undefined') {
        window.location.href = '/login'
      }
    }
    return Promise.reject(error) // always re-reject so callers can still catch
  }
)

// ── Response interceptor: automatic token refresh (retry pattern) ─────
let isRefreshing = false
let refreshQueue: Array<(token: string) => void> = []

api.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config as typeof error.config & { _retry?: boolean }

    if (error.response?.status === 401 && !originalRequest?._retry) {
      if (isRefreshing) {
        // Queue requests that arrive while refresh is in progress
        return new Promise((resolve) => {
          refreshQueue.push((newToken: string) => {
            if (originalRequest) {
              originalRequest.headers.Authorization = `Bearer ${newToken}`
              resolve(api(originalRequest))
            }
          })
        })
      }

      originalRequest._retry = true
      isRefreshing = true

      try {
        const { data } = await axios.post('/auth/refresh', {
          refreshToken: localStorage.getItem('refresh_token'),
        })
        const newToken = data.access_token
        localStorage.setItem('access_token', newToken)

        // Flush queued requests with the new token
        refreshQueue.forEach((cb) => cb(newToken))
        refreshQueue = []

        // Retry the original failed request
        if (originalRequest) {
          originalRequest.headers.Authorization = `Bearer ${newToken}`
          return api(originalRequest)
        }
      } catch (refreshError) {
        refreshQueue = []
        window.location.href = '/login'
        return Promise.reject(refreshError)
      } finally {
        isRefreshing = false
      }
    }

    return Promise.reject(error)
  }
)

// ── Response interceptor: unwrap data envelope ────────────────────────
// If every API response is { "data": {...}, "meta": {...} },
// unwrap automatically so callers get data directly
api.interceptors.response.use((response) => {
  // Only unwrap when the envelope pattern is present
  if (response.data && 'data' in response.data) {
    return { ...response, data: response.data.data }
  }
  return response
})

The token refresh pattern requires a queue (refreshQueue) to handle multiple simultaneous requests that all receive 401 — without the queue, each 401 triggers a separate refresh call, causing race conditions and multiple redirect loops. The _retry flag on the original request config prevents infinite retry loops (a 401 on the refresh endpoint itself would otherwise re-trigger the interceptor). Always call Promise.reject(error) at the end of the onRejected handler so calling code can still catch errors that the interceptor does not handle.

Handling AxiosError: Status Codes, Network Errors, and Timeouts

Axios throws AxiosError for any response with a 4xx or 5xx status, for network failures (no response), and for request timeouts. Use axios.isAxiosError(error) to narrow the type. The three branches in every Axios error handler correspond to three distinct failure modes: error.response (server responded with an error status), error.request (no response received), and neither (an error during request setup).

import axios from 'axios'
import type { AxiosError } from 'axios'

interface ApiError {
  message: string
  code: string
  details?: Record<string, string[]>
}

// ── Full error handling pattern ────────────────────────────────────────
async function fetchUser(id: number): Promise<User> {
  try {
    const { data } = await axios.get<User>(`/api/users/${id}`)
    return data
  } catch (error) {
    if (axios.isAxiosError(error)) {
      const axiosError = error as AxiosError<ApiError>

      if (axiosError.response) {
        // ✅ Server responded with 4xx or 5xx
        const { status, data: errorBody } = axiosError.response
        console.log('HTTP status:', status)
        console.log('Error body:', errorBody)  // typed as ApiError

        switch (status) {
          case 400: throw new Error(errorBody?.message ?? 'Invalid request')
          case 401: throw new Error('Authentication required')
          case 403: throw new Error('Permission denied')
          case 404: throw new Error('User not found')
          case 422: {
            const fieldErrors = errorBody?.details
            throw new Error(`Validation failed: ${JSON.stringify(fieldErrors)}`)
          }
          case 429: throw new Error('Rate limited — please wait before retrying')
          case 500: throw new Error('Server error — please try again later')
          default:  throw new Error(`Request failed with status ${status}`)
        }
      } else if (axiosError.request) {
        // ✅ Request was sent but no response received
        if (axiosError.code === 'ECONNABORTED') {
          throw new Error('Request timed out — the server took too long to respond')
        }
        throw new Error('Network error — check your internet connection')
      } else {
        // ✅ Error occurred before the request was sent (config error)
        throw new Error(`Request setup error: ${axiosError.message}`)
      }
    }

    // Non-Axios error (programming error, type error, etc.)
    throw error
  }
}

// ── Distinguish timeout from other network errors ──────────────────────
try {
  await api.get('/slow-endpoint', { timeout: 3000 })
} catch (error) {
  if (axios.isAxiosError(error) && error.code === 'ECONNABORTED') {
    console.log('Timed out after 3 seconds')
  }
}

// ── Access server validation errors (422 Unprocessable Entity) ─────────
try {
  await api.post('/api/users', { name: '', email: 'not-an-email' })
} catch (error) {
  if (axios.isAxiosError(error) && error.response?.status === 422) {
    const validationErrors = error.response.data?.details
    // { name: ['is required'], email: ['is not a valid email'] }
    Object.entries(validationErrors ?? {}).forEach(([field, messages]) => {
      console.log(`${field}: ${messages.join(', ')}`)
    })
  }
}

// ── Type guard for AxiosError without axios.isAxiosError() ────────────
function isAxiosError<T>(error: unknown): error is AxiosError<T> {
  return axios.isAxiosError(error)
}

error.response.data is the most important property for JSON APIs — it contains the parsed JSON error body the server sent back, which typically includes a human-readable message, an error code, and validation field errors. Always type it with a generic: AxiosError<ApiError> gives error.response.data the type ApiError. The error.code === 'ECONNABORTED' check is the only reliable way to detect timeouts — Axios does not expose a separate timeout error type. Network errors (no response) set error.code to 'ERR_NETWORK' in browser environments and 'ECONNREFUSED' or 'ENOTFOUND' in Node.js.

transformRequest and transformResponse: Custom Serialization

transformRequest and transformResponse are arrays of functions that run on the request body and response data respectively, before Axios's built-in serialization/deserialization. transformRequest replaces the default JSON.stringify() behavior; transformResponse replaces the default JSON.parse() behavior. Use them to add custom serialization logic, convert date strings to Date objects, or apply a camelCase/snake_case transform layer.

import axios from 'axios'

// ── transformResponse: convert ISO date strings to Date objects ────────
const api = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 5000,
  transformResponse: [
    // First: Axios's default JSON.parse (replicate it here since we override)
    (data: string) => {
      if (typeof data !== 'string') return data
      try {
        return JSON.parse(data)
      } catch {
        return data // non-JSON response — return as-is
      }
    },
    // Second: convert ISO 8601 date strings to Date objects
    (data: unknown): unknown => {
      const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/

      function convertDates(obj: unknown): unknown {
        if (typeof obj === 'string' && ISO_DATE_REGEX.test(obj)) {
          return new Date(obj)
        }
        if (Array.isArray(obj)) {
          return obj.map(convertDates)
        }
        if (obj !== null && typeof obj === 'object') {
          return Object.fromEntries(
            Object.entries(obj as Record<string, unknown>).map(
              ([key, value]) => [key, convertDates(value)]
            )
          )
        }
        return obj
      }

      return convertDates(data)
    },
  ],
})

// After this transform: response.data.createdAt is a Date, not a string

// ── transformRequest: convert camelCase keys to snake_case ────────────
function camelToSnake(str: string): string {
  return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
}

function convertKeysToSnakeCase(obj: unknown): unknown {
  if (Array.isArray(obj)) return obj.map(convertKeysToSnakeCase)
  if (obj !== null && typeof obj === 'object') {
    return Object.fromEntries(
      Object.entries(obj as Record<string, unknown>).map(
        ([key, value]) => [camelToSnake(key), convertKeysToSnakeCase(value)]
      )
    )
  }
  return obj
}

const snakeCaseApi = axios.create({
  baseURL: 'https://legacy-api.example.com',
  timeout: 5000,
  transformRequest: [
    (data: unknown, headers: Record<string, string>) => {
      if (data && typeof data === 'object') {
        headers['Content-Type'] = 'application/json'
        return JSON.stringify(convertKeysToSnakeCase(data))
      }
      return data
    },
  ],
  // Also convert response keys from snake_case to camelCase:
  transformResponse: [
    (data: string) => {
      try {
        const parsed = JSON.parse(data)
        return convertKeysToCamelCase(parsed)
      } catch {
        return data
      }
    },
  ],
})

// Usage: send camelCase, receive camelCase — API speaks snake_case transparently
await snakeCaseApi.post('/users', { firstName: 'Alice', lastName: 'Smith' })
// Sends: {"first_name":"Alice","last_name":"Smith"}

// ── Per-request transform override ────────────────────────────────────
// Override transformResponse for one request only
const { data } = await api.get('/raw-data', {
  transformResponse: [(data: string) => data], // return raw string, no JSON.parse
})
console.log(typeof data) // 'string'

transformRequest and transformResponse accept arrays of functions — each function receives the output of the previous one, forming a pipeline. When you override transformResponse, Axios's built-in JSON.parse() is removed; you must replicate it in the first function of your array. The same applies to transformRequest — the default is [JSON.stringify]; replacing the array means replacing the stringify step. Use axios.defaults.transformResponse to access the default transforms if you want to extend rather than replace: transformResponse: [...(axios.defaults.transformResponse as []), myCustomTransform].

Key Terms

axios instance
A configured copy of Axios created with axios.create(({ baseURL, timeout, headers })). An instance carries default configuration that applies to every request made through it, including base URL, timeout, default headers, and interceptors. Instances are independent — interceptors and defaults on one instance do not affect another or the global axios object. Create one instance per API origin: a public API instance, an authenticated API instance, and a third-party API instance are all separate objects. Interceptors registered on an instance (via instance.interceptors.request.use()) only run for requests made with that instance.
interceptor
A function registered with axios.interceptors.request.use() or axios.interceptors.response.use() that runs before every request is sent (request interceptor) or after every response is received (response interceptor). Request interceptors receive and return the AxiosRequestConfig object — modify it to add headers, params, or body transformations. Response interceptors receive the AxiosResponse (on success) or AxiosError (on failure) — use them for global error handling, token refresh, and response unwrapping. Interceptors run in LIFO order for requests (last-registered first) and FIFO order for responses (first-registered first). Remove an interceptor by saving its ID and calling axios.interceptors.request.eject(id).
AxiosError
The error class Axios throws when a request fails. It extends JavaScript's built-in Error and adds Axios-specific properties: response (an AxiosResponse when the server responded with 4xx/5xx, undefined otherwise), request (the request object when the request was sent but no response was received), config (the AxiosRequestConfig that triggered the error), and code (a string code like 'ECONNABORTED' for timeouts or 'ERR_NETWORK' for network failures). Use axios.isAxiosError(error) as a type guard to narrow unknown to AxiosError. The generic AxiosError<T> types error.response.data as T.
transformRequest
An array of functions in the Axios config that transform the request body before it is sent. Each function receives (data, headers) and must return the transformed data. The default is [JSON.stringify] — when you provide your own array, you replace the default entirely. Use transformRequest to apply custom serialization (snake_case conversion, encryption, compression), add computed headers based on body content, or serialize to a format other than JSON. The functions run in order, passing the output of each to the next. Override per-request by passing transformRequest in the request config object.
CancelToken
The legacy Axios cancellation mechanism, deprecated since Axios v0.22. It was replaced by the standard AbortController API, which Axios v1+ supports natively via the signal request config option. Old pattern: const source = axios.CancelToken.source(); axios.get(url, { cancelToken: source.token }); source.cancel(). New pattern: const controller = new AbortController(); axios.get(url, { signal: controller.signal }); controller.abort(). The AbortController pattern is identical to the fetch cancellation API, making knowledge transferable between the two. Use axios.isCancel(error) to detect cancellation in catch blocks regardless of which mechanism was used.
baseURL
A configuration option for axios.create() that sets a prefix prepended to all relative URLs in requests made with that instance. If baseURL is 'https://api.example.com/v2', then api.get('/users') sends to https://api.example.com/v2/users. Absolute URLs in requests (starting with http:// or https://) are not affected by baseURL. Trailing slashes on baseURL and leading slashes on the request path are handled consistently — 'https://api.example.com/v2' + '/users' and 'https://api.example.com/v2/' + 'users' both produce the same URL. Change baseURL at runtime via instance.defaults.baseURL = newUrl.

FAQ

Does Axios automatically parse JSON responses?

Yes. Axios automatically calls JSON.parse() on every response whose Content-Type header contains application/json. The parsed object is at response.data — no manual await res.json() or JSON.parse() needed. This contrasts with the native fetch() API, which requires an explicit await res.json() call. If the server returns a non-JSON response (HTML error page, plain text), response.data contains the raw string — Axios does not throw, it simply skips JSON parsing when the Content-Type is absent or non-JSON. Override the parsing logic with transformResponse in the instance config.

How do I send a JSON body with Axios POST?

Pass a plain JavaScript object as the second argument to axios.post(): await axios.post('/api/users', { name: "Alice", email: "alice@example.com" }). Axios automatically calls JSON.stringify() on the object and sets Content-Type: application/json. Never call JSON.stringify() yourself when the body is an object — doing so double-encodes it. The same automatic behavior applies to axios.put() and axios.patch(). The response body (response.data) is also automatically parsed from JSON. To send a raw pre-serialized JSON string, pass it directly and set the header manually: axios.post(url, jsonString, { headers: { "Content-Type": "application/json" } }).

How do I add an Authorization header to every Axios request?

Use a request interceptor on your axios instance: api.interceptors.request.use(config => { config.headers.Authorization = `Bearer ${token}`; return config; }). The interceptor runs before every request and reads the current token dynamically, ensuring it always uses the latest value — unlike static headers set at instance creation time, which become stale after a token refresh. For client-side code, read from localStorageor a cookie inside the interceptor. For server-side Next.js Route Handlers, read from the current request's cookies and create a new axios instance per request — never attach user tokens to a shared server-side instance.

How do I handle errors in Axios?

Wrap requests in try/catch and use axios.isAxiosError(error) to narrow the type. Three branches: (1) error.response exists — server returned 4xx/5xx; read error.response.status for the code and error.response.data for the parsed JSON error body. (2) error.request exists but error.response is undefined — request sent but no response received (network error or timeout); check error.code === 'ECONNABORTED' for timeouts. (3) Neither — error occurred before the request was sent (config error). For global handling, use a response interceptor: api.interceptors.response.use(res => res, error => { /* handle globally */ return Promise.reject(error); }).

What is the difference between Axios and fetch for JSON?

Three key differences: (1) Automatic JSON — Axios auto-serializes request bodies and auto-parses responses; fetch requires JSON.stringify() on requests and await res.json() on responses. (2) Error handling — Axios throws on 4xx/5xx; fetch only throws on network failure and returns a Response with res.ok === false for HTTP errors, which is easy to forget. (3) Interceptors — Axios has built-in request/response interceptors; fetch has none natively. Axios also provides upload progress events (onUploadProgress). fetch advantages: zero bundle size (native), works in all edge environments (Cloudflare Workers, Deno), and supports response body streaming. In Next.js App Router server components, the extended fetch with cache options is the recommended pattern.

How do I cancel an Axios request?

Use the AbortController API (Axios v1+): const controller = new AbortController(); await axios.get(url, { signal: controller.signal }); controller.abort(). This is the standard approach — identical to cancelling a fetch request. The legacy CancelToken API is deprecated since Axios v0.22. In React useEffect, cancel on cleanup: return () => controller.abort() — essential in React 18 StrictMode, which double-invokes effects in development. Detect cancellation in catch: if (axios.isCancel(error)) return.

How do I use Axios with TypeScript?

Axios ships its own TypeScript types — no @types/axios package needed. Use the generic parameter to type response.data: const { data } = await axios.get<User>('/api/users/1')data is typed as User. For errors: AxiosError<ApiError> types error.response.data as ApiError. Important: TypeScript generics are compile-time assertions only — Axios does not validate the actual response shape at runtime. For runtime safety, parse with Zod after receiving the response: const user = UserSchema.parse(data). Type the axios instance as AxiosInstance from the axios package when passing it as a function parameter.

How do I set a timeout in Axios?

Set the timeout option in milliseconds on your instance: axios.create({ baseURL: "...", timeout: 5000 }). The default is 0 (no timeout) — always set a timeout in production to prevent slow servers from hanging your application indefinitely. For user-facing requests, 5000 ms (5 seconds) is a reasonable default; for background jobs, 30000–60000 ms. Override per-request: await api.get('/slow-endpoint', { timeout: 30000 }). Detect timeouts in catch: if (axios.isAxiosError(error) && error.code === 'ECONNABORTED') { /* timed out */ }. The ECONNABORTED code distinguishes timeouts from other network errors.

Further reading and primary sources

  • Axios Documentation: Request ConfigComplete reference for all Axios request options including timeout, transformRequest, transformResponse, and validateStatus
  • Axios Documentation: InterceptorsOfficial guide to request and response interceptors, eject patterns, and async interceptors
  • JSON.parse() in JavaScriptHow JSON.parse() works under the hood — the same function Axios calls automatically on JSON responses
  • fetch() API JSONUsing the native fetch API for JSON requests — manual serialization, error checking, and abort patterns
  • TypeScript JSON patternsAdvanced TypeScript patterns for JSON — discriminated unions, type guards, and Zod runtime validation
  • JSON securitySecurity considerations for JSON APIs — prototype pollution, injection, and safe parsing patterns