While working on a feature that fetched data using React Query in a Next.js App Router project, I came across an issue where some queries were stuck in the loading
state indefinitely. After a bit of digging, I discovered that the root cause was related to how server actions behave in Next.js β and how they canβt be called directly from the client.
In this post, Iβll explain the problem, how I investigated it, and how Next.js API Routes helped solve it.
π§Ή The Problem
We were using useQuery
from React Query in a client component to fetch data:
export const useGetCommentsQuery = (interviewId: string) =>
useQuery({
queryKey: ["interviewComments", interviewId],
queryFn: () => getInterviewComments(interviewId), // β server action
enabled: !!interviewId,
});
The function getInterviewComments
is a server action β a function that runs on the server and can access the database (usually via Prisma or internal logic).
However, because this code is running inside a client component, and useQuery
is executed in the browser, we were effectively trying to call a server-only function from the client, which doesnβt work in Next.js.
π Symptoms
- The hook stayed stuck in
loading
. - No data was returned.
- No errors were thrown.
- Console logs inside the server action were never triggered.
π Why It Happens (Next.js Context)
In Next.js (with the App Router):
- Server actions can only be called from the server (e.g., in a Server Component or via form submissions).
- When you try to call them directly from the client (like inside a
useQuery
hook), they are not executed β and the Promise never resolves. - This leads to React Query hooks staying stuck in
loading
forever.
This isn't a bug in React Query β it's simply a misuse of where server-only code is allowed to run.
Hereβs a visual of what I was (wrongly) attempting:
![Incorrect: Client trying to call server action directly(https://dev-to-uploads.s3.amazonaws.com/uploads/articles/y2gzar0p6gb61ooatkfd.png)
β The Solution: Use API Routes
To solve the problem, I used Next.js API Routes (via app/api
) to expose server-side logic in a way that the client could consume safely using fetch
.
Step 1: Create an API route
// app/api/interviews/[id]/comments/route.ts
import { NextResponse } from "next/server";
import { getInterviewComments } from "@/app/(admin)/exams/_actions/comments/get-interview-comments";
export async function GET(
req: Request,
{ params }: { params: { id: string } }
) {
try {
const comments = await getInterviewComments(params.id);
return NextResponse.json(comments);
} catch (error) {
console.error("Failed to fetch comments:", error);
return new NextResponse("Failed to fetch comments", { status: 500 });
}
}
Step 2: Update the React Query hook to use fetch
export const useGetCommentsQuery = (interviewId: string) =>
useQuery({
queryKey: ["interviewComments", interviewId],
queryFn: async () => {
const res = await fetch(`/api/interviews/${interviewId}/comments`);
if (!res.ok) throw new Error("Failed to fetch comments");
return res.json();
},
enabled: !!interviewId,
});
Hereβs what the correct architecture looks like now:
π Outcome
- The
useQuery
hook now resolves correctly. - Loading state behaves as expected.
- Server-only logic stays on the server.
- The API route acts as a clean and secure middle layer.
π§ Key Takeaways
- In Next.js App Router, server actions cannot be called directly from the client.
- React Query expects a
queryFn
that works in the browser β server actions do not meet that requirement. - To bridge the gap, use API Routes (
app/api
) to expose server logic in a way that client components can call viafetch
.
If youβre using React Query and seeing loading
states that never resolve, double-check if your queryFn
is trying to call a server-only function directly. If it is β create a proper API route to handle it.
Thanks for reading! Let me know if youβve run into a similar situation or have better patterns to share.
Top comments (0)