Axios JSON: GET, POST, TypeScript Types, and Error Handling

Last updated:

Axios automatically serializes request bodies and parses JSON responses — no manual JSON.stringify or res.json() needed. This guide covers GET/POST with typed responses, custom instances, interceptors for auth, error handling patterns, and when to use fetch instead.

1. Basic GET and POST

import axios from 'axios'

// GET — response.data is already parsed JSON
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'

// POST — object is auto-serialized to JSON
const createResponse = await axios.post('https://api.example.com/users', {
  name: 'Bob',
  email: 'bob@example.com',
})
// Content-Type: application/json is set automatically
console.log(createResponse.data) // { id: 2, name: 'Bob', ... }

// PUT / PATCH / DELETE
await axios.put('/api/users/1', { name: 'Alice Updated' })
await axios.patch('/api/users/1', { email: 'new@example.com' })
await axios.delete('/api/users/1')

2. TypeScript Generics for Typed Responses

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
}

// Single resource
const { data: user } = await axios.get<User>('/api/users/1')
// user is typed as User

// Paginated list
const { data: result } = await axios.get<PaginatedResponse<User>>('/api/users', {
  params: { page: 1, perPage: 20 },
})
// result.data is User[]

// POST with request + response types
const { data: created } = await axios.post<User>(
  '/api/users',
  { name: 'Carol', email: 'carol@example.com' } satisfies Omit<User, 'id' | 'role'>
)
// created is typed as User

3. Custom Instance with Auth Interceptor

// lib/api.ts
import axios from 'axios'

export const api = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL ?? 'https://api.example.com',
  timeout: 10_000,
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  },
})

// Attach JWT on every request
api.interceptors.request.use((config) => {
  const token = typeof window !== 'undefined'
    ? localStorage.getItem('token')
    : null
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

// Global response handling
api.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      // Token expired — redirect to login
      window.location.href = '/login'
    }
    return Promise.reject(error)
  }
)

4. Error Handling

import axios from 'axios'

async function fetchUser(id: number) {
  try {
    const { data } = await axios.get<User>(`/api/users/${id}`)
    return data
  } catch (error) {
    if (axios.isAxiosError(error)) {
      if (error.response) {
        // Server responded with 4xx or 5xx
        const { status, data } = error.response
        if (status === 404) throw new Error('User not found')
        if (status === 403) throw new Error('Permission denied')
        // data may contain JSON error message from server
        throw new Error(data?.message ?? `Request failed: ${status}`)
      } else if (error.request) {
        // Request sent, no response (network error, timeout)
        throw new Error('Network error — please check your connection')
      }
    }
    throw error // Re-throw non-Axios errors
  }
}

5. Query Params, Headers, and Config

// GET with query params — becomes /users?page=2&status=active&ids[]=1&ids[]=2
const response = await axios.get('/users', {
  params: {
    page: 2,
    status: 'active',
    ids: [1, 2],
  },
  // paramsSerializer can customize array format:
  // ids[]=1&ids[]=2  (default)
  // ids=1,2          (comma-separated)
})

// Custom headers per request
const response2 = await axios.get('/protected', {
  headers: {
    'X-API-Key': 'my-key',
    'Accept-Language': 'en-US',
  },
})

// POST with both JSON body AND query params
const response3 = await axios.post(
  '/search',
  { filters: { active: true } }, // body
  { params: { page: 1 } }        // query string
)

6. Request Cancellation (React useEffect)

import { useEffect, useState } from 'react'
import axios from 'axios'

function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState<User | null>(null)

  useEffect(() => {
    const controller = new AbortController()

    axios.get<User>(`/api/users/${userId}`, {
      signal: controller.signal,
    })
      .then(res => setUser(res.data))
      .catch(err => {
        if (axios.isCancel(err)) return // cancelled — ignore
        console.error(err)
      })

    return () => controller.abort() // cancel on unmount or userId change
  }, [userId])

  return user ? <div>{user.name}</div> : <div>Loading...</div>
}

7. Axios vs fetch — Quick Comparison

FeatureAxiosfetch
Auto JSON parse✅ response.data❌ requires await res.json()
Auto JSON stringify✅ for objects❌ manual JSON.stringify needed
Throws on 4xx/5xx✅ always❌ only on network failure
Request interceptors✅ built-in❌ need wrapper
Upload progress✅ onUploadProgress❌ not supported
Bundle size~14 KB gzipped0 KB (native)
Edge runtime support⚠️ limited✅ full
Cancel✅ AbortController✅ AbortController

Frequently Asked Questions

How does Axios handle JSON automatically?

Axios automatically handles JSON in both directions. For requests: when you pass a plain JavaScript object as the data option to axios.post(), axios.put(), or axios.patch(), Axios serializes it with JSON.stringify() and sets the Content-Type header to application/json. You do not need to manually stringify or set the content type. For responses: Axios automatically calls JSON.parse() on responses whose Content-Type is application/json, so response.data is already a JavaScript object — no manual parsing needed. This bidirectional automatic handling is the most common reason developers prefer Axios over the native fetch API, which requires res.json() on every response and does not automatically serialize request bodies. You can access the parsed JSON at response.data; Axios wraps the response in an AxiosResponse object that also exposes response.status, response.headers, and response.config.

How do I type Axios responses in TypeScript?

Axios uses generic types for typed responses. The AxiosResponse<T> type wraps the response data as T, and axios.get<T>(), axios.post<T>() etc. return Promise<AxiosResponse<T>>. Example: interface User { id: number; name: string; email: string } const response = await axios.get<User>("/api/users/1"); const user = response.data; // typed as User. For arrays: const response = await axios.get<User[]>("/api/users"); const users = response.data; // User[]. Note that these types are not validated at runtime — Axios does not run the response data through a type guard. The TypeScript type is a compile-time assertion. For runtime safety, use Zod: const UserSchema = z.object({ id: z.number(), name: z.string() }); const user = UserSchema.parse(response.data). This catches API contract violations that TypeScript alone cannot detect.

How do I create an Axios instance with a base URL and default headers?

Create a configured Axios instance with axios.create(): const api = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL ?? "https://api.example.com", timeout: 10000, headers: { "Content-Type": "application/json", "Accept": "application/json", } }). Requests made with this instance use the base URL as a prefix: api.get("/users/1") sends to https://api.example.com/users/1. You can further configure the instance with interceptors for authentication: api.interceptors.request.use(config => { const token = localStorage.getItem("token"); if (token) { config.headers.Authorization = "Bearer " + token } return config }). Create one instance per API origin in your application — do not add auth tokens or API-specific defaults to the global axios object, which would affect all requests. Export your configured instance and import it wherever you make API calls.

How does Axios error handling work for JSON APIs?

Axios throws an error for any response with a 4xx or 5xx status code (unlike fetch, which only throws for network failures). The thrown error is an AxiosError with a response property when the server responded. Pattern: try { const response = await axios.get("/api/users/1") } catch (error) { if (axios.isAxiosError(error)) { if (error.response) { // Server returned 4xx/5xx console.log(error.response.status) // 404, 500, etc. console.log(error.response.data) // parsed JSON error body } else if (error.request) { // Request sent but no response (network error, timeout) console.log("Network error") } } }. The error.response.data field contains the parsed JSON error body from the server — the same automatic JSON parsing that applies to successful responses. For global error handling, use a response interceptor: api.interceptors.response.use(response => response, error => { if (error.response?.status === 401) { redirect("/login") } return Promise.reject(error) }).

What is the difference between axios.get() params and axios.post() data?

params and data are two different request options with completely different behaviors. params appends key-value pairs to the URL query string: axios.get("/users", { params: { page: 2, limit: 10 } }) sends GET /users?page=2&limit=10. It is used for GET requests and any other method where filtering/pagination options go in the URL. data is the request body, serialized to JSON: axios.post("/users", { name: "Alice", email: "a@example.com" }) sends POST /users with a JSON body. It is used for POST, PUT, PATCH requests. A common mistake is confusing the two: GET requests do not have a body (axios.get ignores data), and POST requests do not automatically append to the URL (you must use params explicitly if you need both). You can combine both: axios.post("/search", { filters: [...] }, { params: { page: 1 } }) sends POST with a JSON body and a page query parameter.

How do I send multipart form data with files alongside JSON in Axios?

To send both a file and JSON metadata in one request, use FormData: const formData = new FormData(); formData.append("file", file); formData.append("metadata", JSON.stringify({ name: "avatar", userId: 123 })); const response = await axios.post("/upload", formData, { headers: { "Content-Type": "multipart/form-data" } }). Alternatively, append a Blob for typed JSON: formData.append("metadata", new Blob([JSON.stringify(metadata)], { type: "application/json" })). This lets the server parse the metadata part as JSON without extra string parsing. In Node.js with form-data package: const FormData = require("form-data"); const form = new FormData(); form.append("file", fs.createReadStream("./photo.jpg")); form.append("metadata", JSON.stringify(data), { contentType: "application/json" }). Note: when using FormData, do not manually set the Content-Type header — Axios (and the browser) will automatically set it with the correct multipart boundary.

How do I cancel an Axios request?

Axios v1+ uses the AbortController API (same as fetch) for cancellation. const controller = new AbortController(); const response = await axios.get("/api/data", { signal: controller.signal }); // later: controller.abort(). This is the standard way in modern code. The legacy CancelToken API is deprecated as of Axios v0.22. In React with useEffect, cancel on cleanup: useEffect(() => { const controller = new AbortController(); axios.get("/api/users", { signal: controller.signal }).then(res => setUsers(res.data)).catch(err => { if (axios.isCancel(err)) return; setError(err) }); return () => controller.abort() }, []). React 18 StrictMode double-invokes effects in development, so cleanup-based cancellation is essential to avoid double-fetching. The same pattern works in Vue, Angular (in ngOnDestroy), and Svelte (in onDestroy).

Should I use Axios or fetch for JSON requests in 2025?

Both are valid. The main differences: Axios advantages: automatic JSON serialization/deserialization, throws on 4xx/5xx responses, interceptors for auth/logging, request cancellation (AbortController in v1+), upload progress events (onUploadProgress), automatic XSRF protection, and better TypeScript generics. Runs in Node.js (v12+ native without polyfill). fetch advantages: native browser API (no dependency), available in all modern environments including service workers and Cloudflare Workers, slightly simpler for one-off requests, supports Response.body (streaming). Disadvantages of fetch: requires await res.json() on every response, does not throw on 4xx/5xx (must check res.ok manually), no interceptors (need wrapper). For a new project: use Axios if you are building a complex application with many API calls, auth tokens, and error handling patterns. Use fetch if you want zero dependencies, are deploying to edge environments, or are building a simple utility. In Next.js App Router, use fetch with cache options for server components — the extended fetch API is the recommended pattern there.

Further reading and primary sources