See all blogs

September 6, 2026

Setting Up React Query and Axios the Way I Use Them, Line by Line

Setting Up React Query and Axios the Way I Use Them, Line by LineSetting Up React Query and Axios the Way I Use Them, Line by Line

Reference: TanStack Query (React Query) docs ยท Axios docs ยท Integrating React Query with Server Actions in Next.js 15 โ€” CodeMint

This guide walks through the exact files I use to fetch and mutate data in my Next.js apps: the Axios instance (config/axios.ts), the React Query provider (app/providers/ReactQueryProvider.tsx), and the query hooks (hooks/useStudents.ts). Every line gets its own plain-language explanation and a simple, everyday scenario.

Who this is for: you're comfortable with basic React and Next.js, and you roughly know what an API call is. You don't need any prior React Query experience โ€” every piece is explained from scratch.

Backstory

Data-fetching bugs are sneaky. The code looks simple โ€” call an API, show the data, submit a form โ€” but almost every line exists because of a specific, real problem someone ran into first.

Instead of just showing what the code does, this guide explains why each line exists, using a scenario simple enough that the reasoning sticks. Once you understand the "why," the code stops looking like magic.

Here's the mental model for the whole stack, top to bottom:

  • config/axios.ts โ€” the delivery person: one shared HTTP client
  • app/providers/ReactQueryProvider.tsx โ€” the warehouse assistant: caches and syncs data
  • app/api/v1/.../route.ts โ€” the store itself: the real data, behind the counter
  • actions/*.ts โ€” trusted errands that fetch on your behalf
  • services/*.ts โ€” neat, pre-wrapped receipts
  • hooks/use*.ts โ€” how your screen asks for things

What We Are Explaining

By the end of this post, you'll understand exactly what happens in these files:

config/
  axios.ts
app/
  providers/
    ReactQueryProvider.tsx
  layout.tsx
app/api/v1/students/route.ts
actions/
  students.ts
services/
  students.ts
hooks/
  useStudents.ts

What each one is for:

  • config/axios.ts โ€” one shared Axios instance used everywhere
  • app/providers/ReactQueryProvider.tsx โ€” the QueryClient, its settings, and the provider
  • app/layout.tsx โ€” where the provider is mounted for the whole app
  • app/api/v1/students/route.ts โ€” a real API endpoint (the "store")
  • actions/students.ts โ€” server actions that call the API for you
  • services/students.ts โ€” typed helpers that wrap those actions
  • hooks/useStudents.ts โ€” the React Query hooks your UI actually uses

And why each individual line is there โ€” not just "what it does," but "what breaks if you remove it."


The Big Picture Scenario

Imagine your app needs information that lives far away. Say, a school that keeps all the student records in a big office building downtown โ€” that's your database/API. Your users keep walking up to you and asking, "what's the list of students?"

If you ran to the downtown office every single time someone asked, you'd be exhausted, and so would the office. That's the problem React Query solves.

Now picture three helpers next to you:

  • Axios is your delivery person. You hand Axios a note ("go get the students"), and it knows exactly how to navigate the city, which entrance to use, and how to carry the reply back. Without it, you'd have to write the travel instructions from scratch every single time.
  • React Query is your personal assistant with a notepad. The first time someone asks for the students, you send Axios downtown, get the answer, and write it on the notepad. The second time someone asks, the assistant just reads the notepad โ€” instant. If the assistant thinks the notepad might be out of date, it quietly sends Axios to re-check in the background while still showing the old answer, so the user never stares at a blank screen.
  • The server action is the trusted errand boy โ€” someone the downtown office actually trusts to knock on its door. Your browser-facing code never talks to the office directly; it sends the errand boy, who brings back a neatly signed receipt.

The rest of this guide explains each of those helpers, one file at a time, so you can reproduce this setup in your own app.


Part 1: config/axios.ts โ€” The Delivery Person

The Import

import axios from "axios";

Scenario: Your app speaks JavaScript, but the outside world speaks raw HTTP. axios is the toolkit that knows how to open an HTTP request, carry headers, and translate the response back into a plain JavaScript object. Everything else in this file builds on top of this one import.

The Server Base URL

const serverBaseUrl =
  process.env.NEXT_PUBLIC_APP_URL ||
  process.env.BETTER_AUTH_URL ||
  "http://localhost:3000";

Scenario: When your app is running, it has a front-door address โ€” http://localhost:3000 in development, or your deployed URL in production. This line picks that address, with a couple of fallbacks: NEXT_PUBLIC_APP_URL if it exists, otherwise the auth URL, otherwise a local default. It's a safety net so the server side of your app always knows where it lives when it needs to call its own API.

The Shared Instance

const baseAPI = axios.create({
  baseURL:
    typeof window === "undefined" ? `${serverBaseUrl}/api/v1` : "/api/v1",
  headers: {
    "Content-Type": "application/json",
    "Cache-Control": "no-store, no-cache, must-revalidate",
    "Pragma": "no-cache",
  },
});

Scenario: Instead of telling Axios "go get /api/v1/students" with fresh instructions every time, you create one delivery person (baseAPI) that already knows the neighborhood. axios.create(...) returns a copy of Axios with all these defaults baked in, so every later call stays short and consistent.

baseURL

baseURL: typeof window === "undefined" ? `${serverBaseUrl}/api/v1` : "/api/v1"

Scenario: This is the delivery person remembering the address of the office building. It changes depending on who's sending the request:

  • typeof window === "undefined" is true on the server (Node.js has no window). There, the app must dial out to a full address โ€” http://localhost:3000/api/v1, or the deployed URL โ€” because the server isn't a browser that already knows its own domain.
  • In the browser (window exists), it can use the short relative path /api/v1, and the browser fills in the domain automatically.

Content-Type

"Content-Type": "application/json"

Scenario: This tells the office: "everything I'm sending you is written in JSON." If you send JSON without declaring it, the office may refuse to read it.

Cache-Control

"Cache-Control": "no-store, no-cache, must-revalidate"

Scenario: This is two safety warnings in one. It tells the browser and any caching middlemen: "never make a copy of this reply and reuse it" (no-store), and even if a copy exists somewhere, "always go back to the office to double-check before using it" (no-cache, must-revalidate). You're sending live data requests; an old cached copy could show students who were deleted long ago.

Pragma

"Pragma": "no-cache"

Scenario: An older, legacy version of the same warning โ€” some very old proxies only understand Pragma. It's harmless to include and catches those edge cases.

The Export

export { baseAPI };

Scenario: One delivery person, shared by the whole app. Every file that needs to talk to the API imports this same instance, so the settings are defined once and never duplicated.


Part 2: app/providers/ReactQueryProvider.tsx โ€” The Assistant's Cage

Why "use client"?

"use client";

Scenario: React Query keeps its entire cache inside the browser (client-side), so this file must be a Client Component. The "use client" directive tells Next.js not to try rendering this component on the server.

The Lazy QueryClient

const [queryClient] = useState(
  () =>
    new QueryClient({
      defaultOptions: {
        queries: {
          staleTime: 15 * 60 * 1000, // 15 minutes
          gcTime: 30 * 60 * 1000, // 30 minutes
          refetchOnWindowFocus: false,
          retry: 1,
        },
      },
    })
);

Scenario: The QueryClient is the assistant's cage โ€” a single place that holds the notepad (the cache) and the settings that govern it. Everything about this line is deliberate.

useState(() => new QueryClient({ ... }))

Scenario: Notice the arrow function wrapped inside useState(...). This is the "lazy initializer." If you wrote useState(new QueryClient()) โ€” calling it eagerly โ€” React would create a brand-new cage on every single re-render, flushing the notepad constantly and wasting memory. By passing a function instead, the QueryClient is created exactly once, on the first render, and then reused. This is one of the most common React Query mistakes, and the fix is this one tiny syntax detail.

staleTime

staleTime: 15 * 60 * 1000

Scenario: The assistant's rule for "how old is too old before I bother re-asking?" For 15 minutes, data on the notepad is considered fresh โ€” React Query won't refetch it, no matter how many components ask. 15 * 60 * 1000 is simply 15 minutes in milliseconds (seconds ร— 1000).

gcTime

gcTime: 30 * 60 * 1000

Scenario: "gc" stands for garbage collection. This is how long an unused entry stays on the notepad before being thrown away entirely. Notice it's longer than staleTime: staleTime decides "is this answer still trustworthy?" while gcTime decides "is this answer still worth keeping at all?" The rule of thumb is gcTime must be longer than staleTime, or you'll throw away entries before you even consider them outdated โ€” pointless.

refetchOnWindowFocus

refetchOnWindowFocus: false

Scenario: By default, React Query re-fetches everything the moment you click back into the browser tab. In an admin dashboard where data rarely changes second-to-second, that's dozens of wasted requests. false says: "don't re-run errands just because the user switched tabs."

retry

retry: 1

Scenario: If an errand fails (the office was temporarily closed), the assistant tries once more before giving up and showing the error. This gives transient failures a second chance without hammering a genuinely broken API with ten attempts.

The Provider and Devtools

return (
  <QueryClientProvider client={queryClient}>
    {children}
    <ReactQueryDevtools initialIsOpen={false} />
  </QueryClientProvider>
);

Scenario: <QueryClientProvider client={queryClient}> marks the territory where the assistant operates. Any component under it can call useQuery, useMutation, or useQueryClient and reach this one shared cage. If a component uses these hooks outside the provider, React Query throws a confusing "no QueryClient set" error.

The <ReactQueryDevtools initialIsOpen={false} /> line is your window into the cage โ€” a floating inspector panel (open it in dev with the icon that appears in the corner of your screen) showing every query, its key, its state, and a "refetch" button. Invaluable for debugging. initialIsOpen={false} just means it starts closed, and it only renders in development.


Part 3: app/layout.tsx โ€” Mounting the Assistant

<ReactQueryProvider>
  <ThemeProvider>
    <SidebarProvider>{children}</SidebarProvider>
    <SessionTimeoutWarning />
    <Toaster richColors />
  </ThemeProvider>
</ReactQueryProvider>

Scenario: The provider has to wrap the whole app โ€” every page and every component โ€” so it goes in the root layout, as close to the top as possible. Note the order: ReactQueryProvider is the outermost, or one of the outermost, wrappers. If any component that calls useQuery sits above it in the tree, that component crashes.


Part 4: app/api/v1/students/route.ts โ€” The Store Itself

export async function GET(request: NextRequest) {
  const session = await auth.api.getSession({ headers: request.headers });
  if (!session?.user) {
    return NextResponse.json(
      { success: false, message: "Unauthorized", ... },
      { status: 401 }
    );
  }
 
  const cacheKey = studentCacheKeys.bySchool(schoolId ?? "all");
  const cached = await getCached<StudentBaseType[]>(cacheKey);
  if (cached) {
    return NextResponse.json({
      success: true,
      data: cached,
      message: "... (cached)",
      ...
    });
  }
 
  const students = await db.student.findMany({ ... });
  await setCached(cacheKey, students);
  return NextResponse.json({ success: true, data: students, ... });
}

Scenario: This is the office building itself. It checks who's knocking (auth), looks at the notepad first (a Redis cache โ€” covered in my cache guide), and only if nothing is cached does it go to the actual filing cabinet (the database). The important detail for this post is its response contract: every route answers with the same JSON shape:

{ success: boolean, data: unknown, message: string, error: string | null, status: number }

Both sides of the app (the action caller and your UI) can therefore rely on this shape, which is what makes the next two layers so clean.


Part 5: actions/students.ts โ€” The Trusted Errand Boy

The Directive and Helpers

"use server";
 
import axios from "axios";
import { baseAPI } from "@/config/axios";
import { headers } from "next/headers";
 
async function authHeaders() {
  const incomingHeaders = await headers();
  return { cookie: incomingHeaders.get("cookie") ?? "" };
}

Scenario: "use server" marks every exported function as a Server Action โ€” code that always runs on the server, never shipped to the browser. This is the big idea of this pattern: your browser never talks to the API directly; it asks this trusted errand boy to do it.

authHeaders()

Scenario: When the browser started a request, it sent a cookie header that proves "this user is logged in." But the errand boy runs inside a Server Action โ€” a new context โ€” and Axios won't forward those cookies automatically. So headers() re-reads the incoming request's headers, and this helper returns a fresh cookie header to attach to the outgoing API call. Remove this, and every authenticated request comes back 401.

One Action, End to End

export async function listStudentsAction() {
  try {
    const response = await baseAPI.get("/student", {
      headers: await authHeaders(),
    });
 
    return {
      success: response.data.success,
      data: response.data.data,
      message: response.data.message,
      error: response.data.error,
      status: response.data.status,
    };
  } catch (error) {
    console.error(error);
    return {
      success: false,
      data: [],
      message: "Failed to fetch students",
      error: "Something went wrong. Please try again.",
      status: 500,
    };
  }
}

Scenario: This single action shows the whole errand in motion.

The Request

await baseAPI.get("/student", { headers: await authHeaders() })

Scenario: The actual trip. baseAPI (the delivery person) is told the route (/student), with the authenticated cookie attached so the office knows who's asking.

The Returned Object

Scenario: The action normalizes the reply. Whatever the API returns, the action hands your app back a clean, typed shape: success, data, message, error, status. Your UI never has to dig through Axios's response structure.

try / catch

Scenario: If the office is unreachable (server down, timeout), the request throws. Without a try/catch, a thrown error would crash the whole Server Action. Instead, it's caught, logged, and converted into the same response shape with success: false โ€” your UI handles "we tried and it failed" just as elegantly as "it worked."

axios.isAxiosError(error)

Scenario: Axios errors aren't plain Errors โ€” they carry error.response?.status and error.response?.data. axios.isAxiosError(error) is a type guard that lets you read those extras safely. A neat trick worth adding to your own version: treat a business 4xx response (like "no school linked") as a normal answer โ€” surface the API's own message to the user โ€” and reserve the generic fallback for genuine network failures.


Part 6: services/students.ts โ€” The Neat Receipts

export const handleStudent = {
  async handleListStudentsService() {
    try {
      const students = await listStudentsAction();
      return {
        success: students.success,
        data: students.data,
        message: students.message,
      };
    } catch (error) {
      const errorMessage = error instanceof Error ? error.message : "Unknown error";
      return { success: false, data: [], message: `Failed: ${errorMessage}` };
    }
  },
  // ...create / update / delete follow the same shape
};

Scenario: The service layer is a menu of receipts. It wraps each Server Action in one more try/catch so the calling hook never has to deal with an actual thrown error โ€” every service method always returns the same typed object. It also logs the real error with console.error(...) while returning a safe, user-friendly message. This is what lets your hooks stay tiny and declarative.


Part 7: hooks/useStudents.ts โ€” How Your Screen Asks for Things

The Query

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 
export function useStudents() {
  const queryClient = useQueryClient();
 
  const studentsQuery = useQuery({
    queryKey: ["students", "all"],
    queryFn: handleStudent.handleListStudentsService,
    staleTime: 30000,
    gcTime: 3 * 60 * 1000,
  });
 
  return {
    students: studentsQuery.data?.data ?? [],
    isLoading: studentsQuery.isLoading,
    error:
      studentsQuery.error?.message ??
      (!studentsQuery.data?.success ? studentsQuery.data?.message : null),
    refetch: studentsQuery.refetch,
  };
}

Scenario: This is the actual "ask for the students" โ€” the line your component calls. Everything React Query does (loading state, caching, retrying, syncing) is handled for you; you never write useState + useEffect to fetch data again.

useQueryClient()

Scenario: A handle to the shared cage โ€” the one QueryClient from the provider. You need it later to invalidate queries after a mutation.

queryKey

queryKey: ["students", "all"]

Scenario: The label written on the notepad, made of an array. React Query identifies every cached entry by its key: ["students", "all"] for the list, ["students", "one", id] for a single row. The exact same key must be used for the query and for invalidating it, or the assistant can't find what to cross out. Use stable strings; never build keys from random values or objects that change identity on every render.

queryFn

queryFn: handleStudent.handleListStudentsService

Scenario: The function the assistant runs when it actually needs to go get data. Here it's the service from Part 6 โ€” a promise that resolves to the normalized response object.

staleTime and gcTime (overridden per query)

staleTime: 30000
gcTime: 3 * 60 * 1000

Scenario: These override the provider defaults for this query only. The list is allowed to be re-considered fresh after just 30 seconds (data changes more often here), but the assistant keeps it around for 3 minutes.

students

students: studentsQuery.data?.data ?? []

Scenario: While the query hasn't finished, data is undefined. ?. and ?? [] convert that into a safe empty array, so the component can render .map(...) immediately without crashing.

isLoading

Scenario: The assistant's "hold on, I'm still checking" flag. The component can show a spinner while it's true. No manual state to manage.

error

Scenario: Slightly clever: errors can come from two places โ€” a thrown network error (query.error) or a business error the API returned inside the response object (data.success === false with a message). This line prioritizes the thrown one, then falls back to the API's message. Either way, the component gets a plain string.

refetch

Scenario: A manual "go check again right now" button the component can trigger โ€” for example, after a search or a pull-to-refresh.

The Mutations

const createStudentMutation = useMutation({
  mutationFn: handleStudent.handleCreateStudentService,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ["students"] });
  },
  onError: (error) => {
    console.log(error.message);
  },
});

Scenario: Reading data is a query; changing data is a mutation โ€” create, update, delete. They're separate tools on purpose.

mutationFn

Scenario: The errand that performs the change โ€” again, a service method that resolves to an answer.

onSuccess and invalidateQueries

onSuccess: () => {
  queryClient.invalidateQueries({ queryKey: ["students"] });
}

Scenario: This is the single most important line in the whole stack. After you create a student, the notepad still holds the old list, without the new student. invalidateQueries crosses that entry out and tells the assistant to refetch it. Notice the key here is ["students"] โ€” a partial match โ€” which invalidates both ["students", "all"] and ["students", "one", ...] in one call. Forget this line, and your UI keeps showing stale data even though the database just changed.

onError

Scenario: What to do when the mutation fails โ€” log it, show a toast, whatever fits your app.

Returned Mutation Helpers

return {
  createStudent: createStudentMutation.mutate,
  isCreating: createStudentMutation.isPending,
  // ...
};

Scenario: The hook doesn't run mutations automatically โ€” it hands your component a mutate function to call (for example, from a form's submit handler) and an isPending flag for disabling the button while it runs. This keeps your components looking like forms, not networking code.

The Dependent Query

export function useStudent(id: string) {
  const studentQuery = useQuery({
    queryKey: ["students", "one", id],
    queryFn: () => handleStudent.handleGetStudentService(id),
    enabled: !!id, // don't run until we actually have an id
  });
 
  return {
    student: studentQuery.data?.data ?? null,
    isLoading: studentQuery.isLoading,
  };
}

Scenario: On a detail page you often don't have the id until the parent loads. enabled: !!id means "if there's no id yet, don't even start the errand" โ€” otherwise React Query would fire a request using undefined and cache a bogus entry. Once id arrives, the query automatically runs.


Putting Everything Together

Here's the full flow, in order, the next time your user lands on the students page:

  1. <StudentsPage /> calls useStudents().
  2. Inside the hook, useQuery({ queryKey: ["students", "all"], ... }) asks React Query: "is this on your notepad, and still fresh?"
  3. React Query says "yes, fresh" โ†’ return the cached array instantly. No network call, no loading spinner, no trip to the office.
  4. React Query says "no / stale" โ†’ it runs queryFn, which calls handleStudent.handleListStudentsService(), which calls listStudentsAction().
  5. The server action (actions/students.ts) attaches the user's cookie and sends baseAPI.get("/student", ...).
  6. The Axios instance (config/axios.ts) carries the request to /api/v1/students โ€” full URL on the server, relative in the browser.
  7. The API route checks auth, checks the Redis cache, hits the database if needed, and answers with the standard { success, data, message, error, status } envelope.
  8. The response walks back up through the action โ†’ service โ†’ hook, where React Query writes it to the notepad, marks it fresh for staleTime, and hands your component isLoading: false plus data.
  9. Later, someone creates a student via createStudentMutation.mutate(...). On success, invalidateQueries({ queryKey: ["students"] }) crosses out the old list, and the next read refetches โ€” so your screen never shows a form that saved but a list that forgot.

That's it. Every layer has one job, and the data travels a single, predictable path.


Why Is React Query So Important in an App?

If you only take one thing from this guide, take this. Without React Query, every screen that shows server data has to hand-roll the same four things:

  • Loading state โ€” const [loading, setLoading] = useState(true) before every fetch
  • Error handling โ€” try/catch everywhere, with no standard shape
  • Caching and deduplication โ€” two components asking for the same "students" fire two requests
  • Syncing โ€” after a mutation, you manually call setState or window.location.reload() to "refetch"

React Query takes all four and makes them automatic and consistent:

  1. It reads the cache first, network second. The notepad model โ€” fetch once, serve many โ€” is why your app feels instant and stops hammering your API with identical requests.
  2. It gives you loading/error/"data is here" as plain flags. isLoading, isError, isPending, data, error โ€” no useEffect juggling, no duplicated spinner logic across pages.
  3. It keeps the UI truthful with background refetches. staleTime plus refetchOnMount / refetchOnWindowFocus mean data refreshes quietly in the background; users see the latest information without a full-screen spinner.
  4. It makes "change data, then refresh the screen" a one-liner. invalidateQueries and setQueryData turn messy post-mutation refresh logic into declarative intent โ€” solving the single most common source of "my list is stale" bugs.
  5. It deduplicates and retries for you. Two components on the same page asking for the same key share one request, and transient failures retry instead of showing a scary error.

The takeaway: React Query isn't a "fetching library" โ€” it's a server-state manager. Local state (theme, modals) stays in React; server state (students, posts, scores) lives in the cage, and your components just ask.


Common Errors and Fixes

"No QueryClient Set" Error

You'll see: No QueryClient set, use QueryClientProvider to set one

Your component calls useQuery but isn't inside <QueryClientProvider>. Mount ReactQueryProvider high โ€” in the root layout โ€” and above every component that uses these hooks.

The List Never Updates After Creating, Editing, or Deleting

The mutation forgot queryClient.invalidateQueries({ queryKey: ["students"] }) in onSuccess. The database changed, but the cache didn't. Always pair a write with an invalidation using a key that overlaps the query โ€” remember, partial keys catch the whole family.

The QueryClient Re-Creates and My Cache Flushes Constantly

You wrote useState(new QueryClient()) instead of useState(() => new QueryClient()). Use the lazy-initializer form so the client is created exactly once.

Authenticated Server Action Suddenly Returns 401/403

Axios doesn't forward the caller's cookies inside a Server Action. Attach them explicitly with { headers: await authHeaders() }, using headers() from next/headers.

Errors Print as a Generic Error With No Status Code

Use axios.isAxiosError(error) to narrow the type, then read error.response?.status and error.response?.data for the real status and server message.

The Detail Page Fires a Pointless Request Before the ID Arrives

The query isn't guarded โ€” add enabled: !!id so React Query waits for a real id.

Data Looks Old Forever and Never Refetches

Check staleTime and refetchOnWindowFocus. A long staleTime is a valid choice, but you'll need to refetch manually via refetch() or a mutation, and if refetchOnWindowFocus is false, background refresh won't happen when the user returns to the tab.

Two Tabs or Screens Show Inconsistent Data

They're probably using different query keys for the same data, so the assistant treats them as separate notepad entries. Standardize keys โ€” for example, a small keys object per resource.


Final Checklist

  • One Axios instance in config/axios.ts, with baseURL switching for server vs. client and cache-busting headers
  • QueryClient created lazily with useState(() => new QueryClient({ ... }))
  • gcTime is longer than staleTime
  • ReactQueryProvider mounted in the root layout, above all components that use the hooks
  • Every server action exports the same response shape: { success, data, message, error, status }
  • Server actions forward the caller's cookie with authHeaders()
  • Every resource has a stable queryKey (list, detail, etc.) reused consistently
  • Every mutation's onSuccess calls invalidateQueries with an overlapping key
  • Dependent queries use enabled: !!id so they don't fire before their input exists
  • isAxiosError is used before reading error.response details

Conclusion

The clean mental model:

  • config/axios.ts โ€” one delivery person, pre-configured routes
  • ReactQueryProvider.tsx โ€” one assistant's cage, with sane defaults
  • API route โ€” the real store, behind auth and cache
  • actions/* โ€” trusted errands that speak for the browser
  • services/* โ€” neat typed receipts, never throw
  • hooks/use* โ€” screens just ask; the assistant handles the errands
  • queryKey โ€” the notepad label, used for both reading and invalidating
  • onSuccess + invalidateQueries โ€” the exact moment stale data gets corrected

Once you see it as an assistant with a notepad, a delivery person who knows the route, and a trusted errand boy, none of this code is mysterious anymore. Every line handles one very specific, very human problem: make the first request real, make every repeated request fast, and make sure the cache is never lying.

Resources

  • TanStack Query โ€” Overview
  • TanStack Query โ€” useQuery reference
  • TanStack Query โ€” useMutation reference
  • Axios โ€” Getting started
  • Integrating React Query with Server Actions in Next.js 15 โ€” CodeMint
  • Next.js โ€” Server Actions and Mutations
  • Next.js โ€” headers() API reference

See all blogs