Next.js Data Fetching Guide: Axios + SWR + React Query + Zustand (Complete Setup)
If you're building a Next.js app in 2026, you have too many options for data fetching and state management.
Should you use native fetch? Axios? SWR? React Query? Where does Zustand fit? What about Redux?
This guide cuts through the confusion with:
- When to use each tool (decision tree included)
- Complete production setup (copy-paste ready)
- Best practices from real-world projects
- How to use them together (the right way)
By the end, you'll have a bulletproof data fetching architecture for your Next.js app.
Quick Decision Tree: Which Tools Do You Need?
1. Do you need to fetch data from APIs?
Yes = You need a data fetching library
- Simple APIs, caching needed = Use SWR
- Complex queries, mutations, optimistic updates = Use React Query
- No library needed = Use native
fetchwith Server Components
No = Skip to state management
2. Do you need complex request configuration (auth, retries, interceptors)?
Yes = Add Axios as your HTTP client
No = Stick with nativefetch
3. Do you need client-side state management (UI state, user preferences)?
Yes = Choose based on complexity:
- Simple state (toggles, filters) = Use Zustand
- Complex state (normalized data, time-travel debugging) = Use Redux Toolkit
- Just React = Use
useState+ Context API
The Stack Comparison Table
| Tool | Purpose | Size | Learning Curve | Best For |
|---|---|---|---|---|
| Native fetch | HTTP requests | 0KB | Easy | Server Components, simple APIs |
| Axios | HTTP client | 13KB | Easy | Request/response interceptors |
| SWR | Data fetching + cache | 4.5KB | Medium | Real-time data, auto-refresh |
| React Query | Data fetching + cache | 12KB | Medium | Complex mutations, optimistic updates |
| Zustand | Client state | 1KB | Easy | UI state, user preferences |
| Redux Toolkit | Client state | 11KB | Hard | Complex normalized state |
Let's build each one from scratch.
Part 1: Setting Up Axios (The Foundation)
Why Use Axios Over Native Fetch?
Axios advantages:
- Automatic JSON transformation
- Request/response interceptors (auth tokens, logging)
- Request cancellation
- Better error handling
- Progress tracking for uploads
When NOT to use Axios:
- Server Components (use native
fetchwith caching) - You don't need interceptors
Installation
npm install axios# orpnpm add axiosComplete Axios Setup (Production-Ready)
// lib/api/axios.tsimport axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig, AxiosResponse } from 'axios'; // Create base instanceconst api: AxiosInstance = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api', timeout: 10000, // 10 seconds headers: { 'Content-Type': 'application/json', },}); // Request interceptor (add auth token, logging)api.interceptors.request.use( (config: InternalAxiosRequestConfig) => { // Add auth token if exists const token = localStorage.getItem('auth_token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } // Log request in development if (process.env.NODE_ENV === 'development') { console.log(`[API Request] ${config.method?.toUpperCase()} ${config.url}`); } return config; }, (error: AxiosError) => { console.error('[API Request Error]', error); return Promise.reject(error); }); // Response interceptor (handle errors globally)api.interceptors.response.use( (response: AxiosResponse) => { // Log response in development if (process.env.NODE_ENV === 'development') { console.log(`[API Response] ${response.status} ${response.config.url}`); } return response; }, async (error: AxiosError) => { const originalRequest = error.config; // Handle 401 Unauthorized (token expired) if (error.response?.status === 401 && originalRequest) { try { // Refresh token logic here const newToken = await refreshAuthToken(); // Update token localStorage.setItem('auth_token', newToken); // Retry original request originalRequest.headers.Authorization = `Bearer ${newToken}`; return api(originalRequest); } catch (refreshError) { // Refresh failed, redirect to login window.location.href = '/login'; return Promise.reject(refreshError); } } // Handle 429 Too Many Requests (rate limit) if (error.response?.status === 429) { const retryAfter = error.response.headers['retry-after']; console.warn(`Rate limited. Retry after ${retryAfter} seconds`); } // Log error console.error('[API Error]', { status: error.response?.status, message: error.message, url: error.config?.url, }); return Promise.reject(error); }); // Helper function for token refreshasync function refreshAuthToken(): Promise<string> { const refreshToken = localStorage.getItem('refresh_token'); const response = await axios.post( `${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`, { refresh_token: refreshToken } ); return response.data.access_token;} export default api;Usage Example
// app/api-examples/basic-usage.tsimport api from '@/lib/api/axios'; // GET requestexport async function getUsers() { const response = await api.get('/users'); return response.data;} // POST requestexport async function createUser(data: { name: string; email: string }) { const response = await api.post('/users', data); return response.data;} // PUT requestexport async function updateUser(id: string, data: Partial<User>) { const response = await api.put(`/users/${id}`, data); return response.data;} // DELETE requestexport async function deleteUser(id: string) { const response = await api.delete(`/users/${id}`); return response.data;}Part 2: React Query Setup (Powerful Data Fetching)
Why React Query?
React Query excels at:
- Automatic caching and background refetching
- Optimistic updates
- Pagination and infinite scrolling
- Dependent queries
- Mutation management
When to use React Query:
- Complex data fetching needs
- Real-time updates
- Optimistic UI patterns
Installation
npm install @tanstack/react-query @tanstack/react-query-devtools# orpnpm add @tanstack/react-query @tanstack/react-query-devtoolsComplete React Query Setup
// lib/react-query/client.tsimport { QueryClient, QueryClientProvider } from '@tanstack/react-query'; export const queryClient = new QueryClient({ defaultOptions: { queries: { // Stale time: 5 minutes staleTime: 5 * 60 * 1000, // Cache time: 10 minutes gcTime: 10 * 60 * 1000, // Retry failed requests 3 times retry: 3, // Refetch on window focus refetchOnWindowFocus: false, // Refetch on reconnect refetchOnReconnect: true, }, mutations: { // Retry failed mutations once retry: 1, }, },});// app/providers.tsx'use client'; import { QueryClientProvider } from '@tanstack/react-query';import { ReactQueryDevtools } from '@tanstack/react-query-devtools';import { queryClient } from '@/lib/react-query/client'; export function Providers({ children }: { children: React.ReactNode }) { return ( <QueryClientProvider client={queryClient}> {children} {process.env.NODE_ENV === 'development' && ( <ReactQueryDevtools initialIsOpen={false} /> )} </QueryClientProvider> );}// app/layout.tsximport { Providers } from './providers'; export default function RootLayout({ children,}: { children: React.ReactNode;}) { return ( <html lang="en"> <body> <Providers>{children}</Providers> </body> </html> );}Custom Hooks with React Query + Axios
// hooks/use-users.tsimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';import api from '@/lib/api/axios'; interface User { id: string; name: string; email: string;} // Fetch all usersexport function useUsers() { return useQuery({ queryKey: ['users'], queryFn: async () => { const response = await api.get<User[]>('/users'); return response.data; }, });} // Fetch single userexport function useUser(id: string) { return useQuery({ queryKey: ['users', id], queryFn: async () => { const response = await api.get<User>(`/users/${id}`); return response.data; }, enabled: !!id, // Only run if id exists });} // Create user mutationexport function useCreateUser() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (data: Omit<User, 'id'>) => { const response = await api.post<User>('/users', data); return response.data; }, onSuccess: () => { // Invalidate and refetch users list queryClient.invalidateQueries({ queryKey: ['users'] }); }, });} // Update user mutationexport function useUpdateUser() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ id, ...data }: Partial<User> & { id: string }) => { const response = await api.put<User>(`/users/${id}`, data); return response.data; }, onSuccess: (data) => { // Update cache optimistically queryClient.setQueryData(['users', data.id], data); // Invalidate users list queryClient.invalidateQueries({ queryKey: ['users'] }); }, });} // Delete user mutationexport function useDeleteUser() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (id: string) => { await api.delete(`/users/${id}`); return id; }, onSuccess: (id) => { // Remove from cache queryClient.removeQueries({ queryKey: ['users', id] }); // Invalidate users list queryClient.invalidateQueries({ queryKey: ['users'] }); }, });}Usage in Components
// app/users/page.tsx'use client'; import { useUsers, useCreateUser, useDeleteUser } from '@/hooks/use-users'; export default function UsersPage() { const { data: users, isLoading, error } = useUsers(); const createUser = useCreateUser(); const deleteUser = useDeleteUser(); async function handleCreate() { await createUser.mutateAsync({ name: 'John Doe', email: 'john@example.com', }); } async function handleDelete(id: string) { await deleteUser.mutateAsync(id); } if (isLoading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>; return ( <div> <button onClick={handleCreate}>Create User</button> <ul> {users?.map((user) => ( <li key={user.id}> {user.name} - {user.email} <button onClick={() => handleDelete(user.id)}>Delete</button> </li> ))} </ul> </div> );}Part 3: SWR Setup (Alternative to React Query)
Why SWR?
SWR advantages:
- Smaller bundle size (4.5KB vs 12KB)
- Simpler API
- Built by Vercel (Next.js team)
- Excellent for real-time data
When to use SWR:
- You want something simpler than React Query
- Real-time data with auto-revalidation
- You're using Next.js
Installation
npm install swr# orpnpm add swrSWR Configuration
// lib/swr/config.tsimport { SWRConfiguration } from 'swr';import api from '@/lib/api/axios'; // Global fetcher using Axiosexport const fetcher = async (url: string) => { const response = await api.get(url); return response.data;}; // SWR configurationexport const swrConfig: SWRConfiguration = { fetcher, revalidateOnFocus: true, revalidateOnReconnect: true, refreshInterval: 0, // Disable auto-refresh (set to 5000 for 5s polling) dedupingInterval: 2000, // 2 seconds errorRetryCount: 3, errorRetryInterval: 5000,};// app/providers.tsx (add SWR)'use client'; import { SWRConfig } from 'swr';import { swrConfig } from '@/lib/swr/config'; export function Providers({ children }: { children: React.ReactNode }) { return ( <SWRConfig value={swrConfig}> {children} </SWRConfig> );}Usage with SWR
// hooks/use-users-swr.tsimport useSWR from 'swr';import useSWRMutation from 'swr/mutation';import api from '@/lib/api/axios'; interface User { id: string; name: string; email: string;} // Fetch usersexport function useUsersSWR() { return useSWR<User[]>('/users');} // Fetch single userexport function useUserSWR(id: string) { return useSWR<User>(id ? `/users/${id}` : null);} // Create userexport function useCreateUserSWR() { return useSWRMutation( '/users', async (url, { arg }: { arg: Omit<User, 'id'> }) => { const response = await api.post<User>(url, arg); return response.data; } );} // Delete userexport function useDeleteUserSWR() { return useSWRMutation( '/users', async (url, { arg }: { arg: string }) => { await api.delete(`${url}/${arg}`); return arg; } );}Part 4: Zustand Setup (Lightweight State Management)
Why Zustand?
Zustand advantages:
- Tiny bundle size (1KB)
- No boilerplate
- TypeScript-friendly
- Works outside React
- Simple to learn
When to use Zustand:
- UI state (modals, sidebars, filters)
- User preferences (theme, language)
- Temporary client-side data
Installation
npm install zustand# orpnpm add zustandComplete Zustand Store
// stores/use-app-store.tsimport { create } from 'zustand';import { persist, createJSONStorage } from 'zustand/middleware'; interface User { id: string; name: string; email: string;} interface AppState { // Auth state user: User | null; isAuthenticated: boolean; setUser: (user: User | null) => void; logout: () => void; // UI state isSidebarOpen: boolean; toggleSidebar: () => void; theme: 'light' | 'dark'; setTheme: (theme: 'light' | 'dark') => void; // Filter state searchQuery: string; setSearchQuery: (query: string) => void;} export const useAppStore = create<AppState>()( persist( (set) => ({ // Auth state user: null, isAuthenticated: false, setUser: (user) => set({ user, isAuthenticated: !!user }), logout: () => set({ user: null, isAuthenticated: false }), // UI state isSidebarOpen: true, toggleSidebar: () => set((state) => ({ isSidebarOpen: !state.isSidebarOpen })), theme: 'light', setTheme: (theme) => set({ theme }), // Filter state searchQuery: '', setSearchQuery: (searchQuery) => set({ searchQuery }), }), { name: 'app-storage', // LocalStorage key storage: createJSONStorage(() => localStorage), // Only persist specific fields partialize: (state) => ({ user: state.user, theme: state.theme, isAuthenticated: state.isAuthenticated, }), } ));Usage in Components
// app/dashboard/page.tsx'use client'; import { useAppStore } from '@/stores/use-app-store'; export default function Dashboard() { const { user, theme, setTheme, isSidebarOpen, toggleSidebar } = useAppStore(); return ( <div className={`dashboard theme-${theme}`}> <button onClick={toggleSidebar}> {isSidebarOpen ? 'Close' : 'Open'} Sidebar </button> <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}> Toggle Theme </button> <div> <h1>Welcome, {user?.name}</h1> </div> </div> );}Zustand with Immer (for nested state)
npm install immer// stores/use-cart-store.tsimport { create } from 'zustand';import { immer } from 'zustand/middleware/immer'; interface CartItem { id: string; name: string; quantity: number; price: number;} interface CartState { items: CartItem[]; addItem: (item: CartItem) => void; removeItem: (id: string) => void; updateQuantity: (id: string, quantity: number) => void; clearCart: () => void; total: number;} export const useCartStore = create<CartState>()( immer((set, get) => ({ items: [], addItem: (item) => set((state) => { const existingItem = state.items.find((i) => i.id === item.id); if (existingItem) { existingItem.quantity += item.quantity; } else { state.items.push(item); } }), removeItem: (id) => set((state) => { state.items = state.items.filter((item) => item.id !== id); }), updateQuantity: (id, quantity) => set((state) => { const item = state.items.find((i) => i.id === id); if (item) { item.quantity = quantity; } }), clearCart: () => set((state) => { state.items = []; }), get total() { return get().items.reduce( (sum, item) => sum + item.price * item.quantity, 0 ); }, })));Part 5: Redux Toolkit (For Complex State)
Installation
npm install @reduxjs/toolkit react-reduxRedux Toolkit Setup
// store/store.tsimport { configureStore } from '@reduxjs/toolkit';import authReducer from './slices/auth-slice';import cartReducer from './slices/cart-slice'; export const store = configureStore({ reducer: { auth: authReducer, cart: cartReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: false, }),}); export type RootState = ReturnType<typeof store.getState>;export type AppDispatch = typeof store.dispatch;// store/slices/auth-slice.tsimport { createSlice, PayloadAction } from '@reduxjs/toolkit'; interface AuthState { user: { id: string; name: string; email: string } | null; token: string | null; isAuthenticated: boolean;} const initialState: AuthState = { user: null, token: null, isAuthenticated: false,}; const authSlice = createSlice({ name: 'auth', initialState, reducers: { setUser: (state, action: PayloadAction<AuthState['user']>) => { state.user = action.payload; state.isAuthenticated = !!action.payload; }, setToken: (state, action: PayloadAction<string>) => { state.token = action.payload; }, logout: (state) => { state.user = null; state.token = null; state.isAuthenticated = false; }, },}); export const { setUser, setToken, logout } = authSlice.actions;export default authSlice.reducer;Part 6: Combining Everything (The Right Way)
Architecture Overview
┌─────────────────────────────────────┐│ Next.js App │├─────────────────────────────────────┤│ Server Components (fetch) │ ← Use native fetch with caching├─────────────────────────────────────┤│ Client Components ││ ├─ React Query / SWR │ ← Server state (API data)│ │ └─ Axios (HTTP client) │ ← Request/response handling│ ├─ Zustand │ ← Client state (UI, preferences)│ └─ Redux Toolkit (optional) │ ← Complex normalized state└─────────────────────────────────────┘Complete Integration Example
// app/products/page.tsx'use client'; import { useProducts, useCreateProduct } from '@/hooks/use-products';import { useAppStore } from '@/stores/use-app-store';import { useCartStore } from '@/stores/use-cart-store'; export default function ProductsPage() { // Server state (React Query + Axios) const { data: products, isLoading } = useProducts(); const createProduct = useCreateProduct(); // Client state (Zustand) const { searchQuery, setSearchQuery } = useAppStore(); const { addItem } = useCartStore(); // Filter products based on search const filteredProducts = products?.filter((product) => product.name.toLowerCase().includes(searchQuery.toLowerCase()) ); return ( <div> <input type="text" placeholder="Search products..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} /> {isLoading && <div>Loading...</div>} <ul> {filteredProducts?.map((product) => ( <li key={product.id}> {product.name} - ${product.price} <button onClick={() => addItem({ ...product, quantity: 1 })}> Add to Cart </button> </li> ))} </ul> </div> );}Best Practices & Common Patterns
1. Error Handling Pattern
// hooks/use-products.tsexport function useProducts() { return useQuery({ queryKey: ['products'], queryFn: async () => { try { const response = await api.get('/products'); return response.data; } catch (error) { if (axios.isAxiosError(error)) { throw new Error( error.response?.data?.message || 'Failed to fetch products' ); } throw error; } }, });}2. Optimistic Updates Pattern
export function useUpdateProduct() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (product: Product) => { const response = await api.put(`/products/${product.id}`, product); return response.data; }, onMutate: async (newProduct) => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ['products'] }); // Snapshot previous value const previousProducts = queryClient.getQueryData(['products']); // Optimistically update queryClient.setQueryData(['products'], (old: Product[] = []) => old.map((p) => (p.id === newProduct.id ? newProduct : p)) ); return { previousProducts }; }, onError: (err, newProduct, context) => { // Rollback on error queryClient.setQueryData(['products'], context?.previousProducts); }, onSettled: () => { // Refetch after error or success queryClient.invalidateQueries({ queryKey: ['products'] }); }, });}3. Pagination Pattern
export function useProductsPaginated(page: number) { return useQuery({ queryKey: ['products', 'paginated', page], queryFn: async () => { const response = await api.get(`/products?page=${page}&limit=10`); return response.data; }, keepPreviousData: true, // Keep old data while fetching new page });}4. Infinite Scroll Pattern
import { useInfiniteQuery } from '@tanstack/react-query'; export function useProductsInfinite() { return useInfiniteQuery({ queryKey: ['products', 'infinite'], queryFn: async ({ pageParam = 1 }) => { const response = await api.get(`/products?page=${pageParam}&limit=10`); return response.data; }, getNextPageParam: (lastPage, pages) => { return lastPage.hasMore ? pages.length + 1 : undefined; }, });}Performance Optimization Tips
1. Request Deduplication
React Query and SWR automatically deduplicate requests made within a short time window.
// Multiple components calling this simultaneously = single requestconst { data } = useUsers();2. Prefetching Data
// Prefetch on hoverfunction ProductLink({ id }: { id: string }) { const queryClient = useQueryClient(); return ( <Link href={`/products/${id}`} onMouseEnter={() => { queryClient.prefetchQuery({ queryKey: ['product', id], queryFn: () => fetchProduct(id), }); }} > View Product </Link> );}3. Selective Subscriptions (Zustand)
// Only re-render when theme changesconst theme = useAppStore((state) => state.theme); // NOT this (re-renders on any state change)const { theme } = useAppStore();When to Use What: The Complete Decision Matrix
Common Questions
Common Mistakes to Avoid
1. Mixing Server and Client State in One Place
2. Not Handling Loading States
// Badconst { data } = useUsers();return <div>{data.map(...)}</div>; // Crashes if data is undefined // Goodconst { data, isLoading, error } = useUsers();if (isLoading) return <Spinner />;if (error) return <ErrorMessage error={error} />;return <div>{data?.map(...)}</div>;3. Over-fetching Data
// Bad: Fetching on every renderfunction Component() { const [data, setData] = useState([]); useEffect(() => { fetchData().then(setData); }); // Missing dependency array = infinite loop} // Good: Use React Queryfunction Component() { const { data } = useQuery({ queryKey: ['data'], queryFn: fetchData });}Complete Project Structure
my-nextjs-app/├── app/│ ├── providers.tsx # React Query + SWR providers│ ├── layout.tsx│ └── page.tsx├── lib/│ ├── api/│ │ └── axios.ts # Axios instance + interceptors│ ├── react-query/│ │ └── client.ts # React Query config│ └── swr/│ └── config.ts # SWR config├── stores/│ ├── use-app-store.ts # Global UI state│ └── use-cart-store.ts # Cart state├── hooks/│ ├── use-users.ts # User queries + mutations│ └── use-products.ts # Product queries + mutations└── types/ └── index.ts # TypeScript typesProduction Checklist
Security Best Practices
1. Never Store Tokens in LocalStorage (Production)
// Bad (vulnerable to XSS)localStorage.setItem('token', token); // Better: Use httpOnly cookies (set by server)// Or use secure session management library2. Sanitize User Input
import DOMPurify from 'isomorphic-dompurify'; const sanitized = DOMPurify.sanitize(userInput);3. Implement Rate Limiting
// In your API routesimport rateLimit from 'express-rate-limit'; const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // Limit each IP to 100 requests per window}); app.use('/api/', limiter);Debugging Tips
React Query Devtools
// Open devtoolsimport { ReactQueryDevtools } from '@tanstack/react-query-devtools'; <ReactQueryDevtools initialIsOpen={false} />Zustand Devtools
npm install zustand-devtoolsimport { devtools } from 'zustand/middleware'; const useStore = create( devtools((set) => ({ // your state })));Axios Request Logging
// Already included in our Axios setupapi.interceptors.request.use((config) => { console.log('[API Request]', config.method?.toUpperCase(), config.url); return config;});Further Reading
Official Documentation
Related Posts
Coming soon:
- Next.js Server Components vs Client Components: When to Use What
- Building a Real-Time Dashboard with Next.js + React Query
- Complete Next.js Authentication Guide (2026)
Conclusion
You don't need all these tools. Here's what most Next.js apps actually need:
Minimal Stack (90% of projects):
- Native
fetchin Server Components - React Query + Axios for Client Components
- Zustand for UI state
Full Stack (Complex apps):
- Add SWR for real-time features
- Add Redux Toolkit only if team requires it
The key: Start simple, add complexity only when needed.
Copy the setups from this guide, adjust to your needs, and ship.
Need help architecting your Next.js application? We build production-ready SaaS products with modern tech stacks. Contact Websyro Agency for expert consultation.
