Need help in revalidation using API routes handlers
Unanswered
Gulf menhaden posted this in #help-forum
Gulf menhadenOP
I've developed an application using Next.js 15, utilizing API routes for the backend. However, I'm encountering an issue where, after posting data, the new information only appears upon manually refreshing the page. Despite looking at official documentation, watching tutorials, and reading articles, I haven't found a solution. I'm beginning to think that to achieve active refresh upon table updates, I might need to use Server Actions instead of API route handlers, since whereever I looked at for revalidation, everyone was using server components.
// DataTable.tsx
// Form.tsx
// DataTable.tsx
useEffect(() => {
const fetchClients = async () => {
const response = await fetch("/api/clients", { next: { tags: ['clients'] } });
const data = await response.json();
setClientCells(data);
};
fetchClients();
}, []);
// Form.tsx
const submitHandler = async (data: ClientFormData) => {
const response = await fetch("/api/clients", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
revalidateTag("clients")
reset();
setFiles([]);
};
const onSubmit = handleSubmit(submitHandler);
return(
<form
onSubmit={onSubmit}
className="px-10 space-y-5 my-4">
.....
</form>
)
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const parsedBody = clientSchema.safeParse(body);
const client = await prisma.clients.create({
data: parsedBody.data,
});
return NextResponse.json(
{ message: "Client created successfully", client },
{ status: 201 }
);
} catch (error) {
return NextResponse.json(
{ error: "An unexpected error occurred while creating the client." },
{ status: 500 }
);
}
}
46 Replies
@Gulf menhaden I've developed an application using Next.js 15, utilizing API routes for the backend. However, I'm encountering an issue where, after posting data, the new information only appears upon manually refreshing the page. Despite looking at official documentation, watching tutorials, and reading articles, I haven't found a solution. I'm beginning to think that to achieve active refresh upon table updates, I might need to use Server Actions instead of API route handlers, since whereever I looked at for revalidation, everyone was using server components.
// DataTable.tsx
typescript
useEffect(() => {
const fetchClients = async () => {
const response = await fetch("/api/clients", { next: { tags: ['clients'] } });
const data = await response.json();
setClientCells(data);
};
fetchClients();
}, []);
// Form.tsx
typescript
const submitHandler = async (data: ClientFormData) => {
const response = await fetch("/api/clients", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
revalidateTag("clients")
reset();
setFiles([]);
};
const onSubmit = handleSubmit(submitHandler);
return(
<form
onSubmit={onSubmit}
className="px-10 space-y-5 my-4">
.....
</form>
)
typescript
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const parsedBody = clientSchema.safeParse(body);
const client = await prisma.clients.create({
data: parsedBody.data,
});
return NextResponse.json(
{ message: "Client created successfully", client },
{ status: 201 }
);
} catch (error) {
return NextResponse.json(
{ error: "An unexpected error occurred while creating the client." },
{ status: 500 }
);
}
}
I think you're using revalidateTag incorrectly. According to the [documentation](https://nextjs.org/docs/app/api-reference/functions/revalidateTag), these are used in route handlers and server actions, but not on the client side.
Try implementing revalidateTag before returning the NextResponse with the valid response.
Try implementing revalidateTag before returning the NextResponse with the valid response.
Any details or errors let me know!
@Losti! I think you're using revalidateTag incorrectly. According to the [documentation](https://nextjs.org/docs/app/api-reference/functions/revalidateTag), these are used in route handlers and server actions, but not on the client side.
Try implementing revalidateTag before returning the NextResponse with the valid response.
Gulf menhadenOP
Well before that I was using them in the routehandler but still resulted in no validation
When you do
When you do it inside Server Actions it’s a little different because in a single round rip you talk to the server, then the server does things, and then server responds back with the new Server Component payload to refresh the contents of the page you’re currently on.
So, if you’re calling the Router Handlers from your client components make sure to manually refresh the page you’re currently on with router.refresh() after calling the Endpoint. Or use a Server Action instead. Whatever works best for you.
revalidatePath()
or revalidateTag()
inside Route Handlers, the actual revalidation applies only for the next request of the page, that’s why you are needing to refresh manually to see changes.When you do it inside Server Actions it’s a little different because in a single round rip you talk to the server, then the server does things, and then server responds back with the new Server Component payload to refresh the contents of the page you’re currently on.
So, if you’re calling the Router Handlers from your client components make sure to manually refresh the page you’re currently on with router.refresh() after calling the Endpoint. Or use a Server Action instead. Whatever works best for you.
@Losti! maybe revalidatePath?
Gulf menhadenOP
Same results, even when I write redirect("same page"), the data doesn't refreshes, and I have to manually refresh to see the updated rows.
This is what I get for forgetting important things about next.js, I think you need to add router.refresh()
"use client";
import { useRouter } from 'next/navigation';
const Form = () => {
const router = useRouter();
const submitHandler = async (data: ClientFormData) => {
const response = await fetch("/api/clients", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
router.refresh();
reset();
setFiles([]);
};
const onSubmit = handleSubmit(submitHandler);
return(
<form
onSubmit={onSubmit}
className="px-10 space-y-5 my-4">
.....
</form>
)
}
@Losti! This is what I get for forgetting important things about next.js, I think you need to add router.refresh()
Gulf menhadenOP
I'll right sir. I'll give it a try 🫡
@Losti! tsx
"use client";
import { useRouter } from 'next/navigation';
const Form = () => {
const router = useRouter();
const submitHandler = async (data: ClientFormData) => {
const response = await fetch("/api/clients", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
router.refresh();
reset();
setFiles([]);
};
const onSubmit = handleSubmit(submitHandler);
return(
<form
onSubmit={onSubmit}
className="px-10 space-y-5 my-4">
.....
</form>
)
}
Gulf menhadenOP
Well I tried everything from router.refresh to router.push, but nothing seems to work
It looks like for now I'll have to resort towards: window.location.reload()
Which I know is a hack and does hard refresh, but nothing else seems to work.
router.push("/dashboard/clients");
router.refresh();
It looks like for now I'll have to resort towards: window.location.reload()
Which I know is a hack and does hard refresh, but nothing else seems to work.
Maybe its something to with how I've structured my code, Can you review it for me?
// app/clients/page.tsx
import { ClientInterface } from "@/app/_lib/types";
import ClientsDataTable from "./_components/ClientsDataTable";
async function getClients(): Promise<ClientInterface[]> {
const res = await fetch("http://localhost:3000/api/clients", {cache: "no-cache"});
if (!res.ok) throw new Error("Failed to fetch clients");
return res.json();
}
export default async function ClientsPage() {
const clients = await getClients();
return <ClientsDataTable initialClients={clients} />;
}
// app/clients/_components/ClientsDataTable.tsx
const ClientsDataTable = ({ initialClients }: ClientsDataTableProps) => {
const [clientCells, setClientCells] = useState<ClientInterface[]>(initialClients);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 5;
const filteredClients = useMemo(() =>
clientCells.filter(client =>
client.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
client.website.toLowerCase().includes(searchQuery.toLowerCase())
), [clientCells, searchQuery]);
const totalPages = Math.ceil(filteredClients.length / itemsPerPage);
const paginatedClients = useMemo(() =>
filteredClients.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage),
[filteredClients, currentPage, itemsPerPage]
);
const handlePageChange = useCallback((page: number) => setCurrentPage(page), []);
const handleNextPage = useCallback(() => setCurrentPage(prev => Math.min(prev + 1, totalPages)), [totalPages]);
const handlePreviousPage = useCallback(() => setCurrentPage(prev => Math.max(prev - 1, 1)), []);
return (....)
}
// app/clients/_components/CreateClientForm.tsx
"use client";
import { useCreateClient } from "./useClientForm";
const CreateClientForm = () => {
const { register, errors, isPending, onSubmit, files, setFiles, getRootProps, getInputProps, apiError } = useCreateClient();
return (...)
}
// app/clients/_components/useClientForm.ts
export function useCreateClient() {
const [files, setFiles] = useState<File[]>([]);
const [isPending, setIsPending] = useState(false);
const [apiError, setApiError] = useState<string | null>(null);
const { startUpload } = useUploadThing("imageUploader", {
onUploadError: (err) => {
toast.error(err.message);
},
});
const { register, handleSubmit, formState: { errors }, setValue, reset, setError } = useForm({
resolver: zodResolver(clientSchema),
});
const { getRootProps, getInputProps } = useDropzone(...);
const router = useRouter();
const submitHandler = async (data: ClientFormData) => {
setIsPending(true);
try {
if (files.length > 0) {
const uploadResponse = await startUpload(files);
if (uploadResponse && uploadResponse[0]) data.logo_url = uploadResponse[0].ufsUrl;
}
const response = await fetch("/api/clients", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error((await response.json()).error);
reset();
setFiles([]);
// router.push("/dashboard/clients");
// router.refresh();
window.location.reload()
} catch (error) {
setApiError(error instanceof Error ? error.message : "Failed to create client.");
} finally {
setIsPending(false);
}
};
return { register, errors, isPending, apiError, onSubmit: handleSubmit(submitHandler), files, setFiles, getRootProps, getInputProps };
}
Try swapping the order of these two
// router.push("/dashboard/clients");
// router.refresh();
or wrapping them inside startTransition
// router.push("/dashboard/clients");
// router.refresh();
or wrapping them inside startTransition
Also, make sure that this route handler is calling the
revalidateTag()
or revalidatePath()
:const response = await fetch("/api/clients", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
@LuisLl Try swapping the order of these two
// router.push("/dashboard/clients");
// router.refresh();
or wrapping them inside startTransition
Gulf menhadenOP
I tried both, but I will go ahead and wrap it in startTransition.
router.refresh() makes the server to run the components again and reconciliate the new RSC payload with the current state of the page. As far as I know this is the way it's supposed to be used. To trigger the server to recompute the server components:c
[
useRouter()
from the docs](https://nextjs.org/docs/app/api-reference/functions/use-router#userouter)router.refresh(): Refresh the current route. Making a new request to the server, re-fetching data requests, and re-rendering Server Components. The client will merge the updated React Server Component payload without losing unaffected client-side React (e.g. useState) or browser state (e.g. scroll position).
@LuisLl router.refresh() makes the server to run the components again and reconciliate the new RSC payload with the current state of the page. As far as I know this is the way it's supposed to be used. To trigger the server to recompute the server components:c
Gulf menhadenOP
Also quick question: for my CRUD app would it have been better to use server components instead of API routes?
It seems like that's what most developers are using these days to build their apps.
The best practice with the best DX is:
- Use Server Components to fetch data
- User Server Actions to mutate data
- Use Server Components to fetch data
- User Server Actions to mutate data
@LuisLl The best practice with the best DX is:
- Use Server Components to fetch data
- User Server Actions to mutate data
Gulf menhadenOP
So server actions/components over route handlers then?
Yes, best DX is Server Components (fetch data) + Server Actions (mutate data).
Route Handlers remain useful for exposing endpoints for your client components to fetch data exclusively from the Client, in a scenario where you can't fetch in Server components and pass the data down as props to your Client components. Also, for external clients/services to reach, like webhooks, websockets, cron jobs, etc.
Route Handlers remain useful for exposing endpoints for your client components to fetch data exclusively from the Client, in a scenario where you can't fetch in Server components and pass the data down as props to your Client components. Also, for external clients/services to reach, like webhooks, websockets, cron jobs, etc.
I still can't understand why
EDIT: I just implemented it and It works for me:
revalidateTag()
/ revalidatePath()
in Route Handler + router.refresh()
on client doesn't work as you say. Could you share your Route Handler btw?EDIT: I just implemented it and It works for me:
// /api/todos/route.ts
export async function POST(request: NextRequest) {
const body = await request.json();
const { id } = body;
await db.delete(todosTable).where(eq(todosTable.id, id));
revalidatePath("/todos");
return NextResponse.json({ message: "Deleted" });
}
// a client component button
<Button
variant={"ghost"}
onClick={async () => {
await fetch("/api/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: todo.id }),
});
router.refresh();
}}
>
<Trash />
</Button>
// my page.tsx which is a server component
export const dynamic = "force-dynamic";
export default async function TodosPage() {
const todos = await db.select().from(todosTable).orderBy(todosTable.id);
@LuisLl I still can't understand why `revalidateTag()` / `revalidatePath()` in Route Handler + `router.refresh()` on client doesn't work as you say. Could you share your Route Handler btw?
**EDIT**: I just implemented it and It works for me:
ts
// /api/todos/route.ts
export async function POST(request: NextRequest) {
const body = await request.json();
const { id } = body;
await db.delete(todosTable).where(eq(todosTable.id, id));
revalidatePath("/todos");
return NextResponse.json({ message: "Deleted" });
}
// a client component button
<Button
variant={"ghost"}
onClick={async () => {
await fetch("/api/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: todo.id }),
});
router.refresh();
}}
>
<Trash />
</Button>
// my page.tsx which is a server component
export const dynamic = "force-dynamic";
export default async function TodosPage() {
const todos = await db.select().from(todosTable).orderBy(todosTable.id);
Gulf menhadenOP
Ok so I found some sort of solution, I think there was a problem with how I was fetching the data. I only had to insert router.refresh at the end of the custom hook. Here is how it looks now:
import { ClientInterface } from "@/app/_lib/types";
import ClientsDataTable from "./_components/ClientsDataTable";
async function getClients(): Promise<ClientInterface[]> {
const res = await fetch("http://localhost:3000/api/clients");
if (!res.ok) throw new Error("Failed to fetch clients");
return res.json();
}
export default async function ClientsPage() {
const clients = await getClients();
return <ClientsDataTable initialClients={clients} />;
}
const ClientsDataTable = ({ initialClients }: ClientsDataTableProps) => {
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 5;
const filteredClients = useMemo(() =>
initialClients.filter(client =>
client.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
client.website.toLowerCase().includes(searchQuery.toLowerCase())
), [initialClients, searchQuery]);
const totalPages = Math.ceil(filteredClients.length / itemsPerPage);
const paginatedClients = useMemo(() =>
filteredClients.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage),
[filteredClients, currentPage, itemsPerPage]
);
....
@Gulf menhaden typescript
// app/clients/_components/ClientsDataTable.tsx
const ClientsDataTable = ({ initialClients }: ClientsDataTableProps) => {
const [clientCells, setClientCells] = useState<ClientInterface[]>(initialClients);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 5;
const filteredClients = useMemo(() =>
clientCells.filter(client =>
client.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
client.website.toLowerCase().includes(searchQuery.toLowerCase())
), [clientCells, searchQuery]);
const totalPages = Math.ceil(filteredClients.length / itemsPerPage);
const paginatedClients = useMemo(() =>
filteredClients.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage),
[filteredClients, currentPage, itemsPerPage]
);
const handlePageChange = useCallback((page: number) => setCurrentPage(page), []);
const handleNextPage = useCallback(() => setCurrentPage(prev => Math.min(prev + 1, totalPages)), [totalPages]);
const handlePreviousPage = useCallback(() => setCurrentPage(prev => Math.max(prev - 1, 1)), []);
return (....)
}
Gulf menhadenOP
Here I was passing the date that I fetched from the db to the clientCell state hook, which I think was the root of the problem (?)
@LuisLl I still can't understand why `revalidateTag()` / `revalidatePath()` in Route Handler + `router.refresh()` on client doesn't work as you say. Could you share your Route Handler btw?
**EDIT**: I just implemented it and It works for me:
ts
// /api/todos/route.ts
export async function POST(request: NextRequest) {
const body = await request.json();
const { id } = body;
await db.delete(todosTable).where(eq(todosTable.id, id));
revalidatePath("/todos");
return NextResponse.json({ message: "Deleted" });
}
// a client component button
<Button
variant={"ghost"}
onClick={async () => {
await fetch("/api/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: todo.id }),
});
router.refresh();
}}
>
<Trash />
</Button>
// my page.tsx which is a server component
export const dynamic = "force-dynamic";
export default async function TodosPage() {
const todos = await db.select().from(todosTable).orderBy(todosTable.id);
Gulf menhadenOP
Sure here it is
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const parsedBody = clientSchema.safeParse(body);
if (!parsedBody.success) {
return NextResponse.json(
{ error: parsedBody.error.flatten().fieldErrors },
{ status: 400 }
);
}
const client = await prisma.clients.create({
data: parsedBody.data,
});
return NextResponse.json(
{ message: "Client created successfully", client },
{ status: 201 }
);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2002") {
return NextResponse.json(
{ error: "Email is already in use." },
{ status: 409 }
);
}
}
return NextResponse.json(
{ error: "An unexpected error occurred while creating the client." },
{ status: 500 }
);
}
}
It works now even without the use of revalidatePath.
@LuisLl Yes, best DX is Server Components (fetch data) + Server Actions (mutate data).
Route Handlers remain useful for exposing endpoints for your client components to fetch data exclusively from the Client, in a scenario where you can't fetch in Server components and pass the data down as props to your Client components. Also, for external clients/services to reach, like webhooks, websockets, cron jobs, etc.
Gulf menhadenOP
Pardon my ignorance but what is a DX?
@Gulf menhaden Pardon my ignorance but what is a DX?
"Developer Experience"
@Gulf menhaden It works now even without the use of revalidatePath.
Great, are you still having problems retrieving the data, or not anymore?
@Gulf menhaden Ok so I found some sort of solution, I think there was a problem with how I was fetching the data. I only had to insert router.refresh at the end of the custom hook. Here is how it looks now:
typescript
import { ClientInterface } from "@/app/_lib/types";
import ClientsDataTable from "./_components/ClientsDataTable";
async function getClients(): Promise<ClientInterface[]> {
const res = await fetch("http://localhost:3000/api/clients");
if (!res.ok) throw new Error("Failed to fetch clients");
return res.json();
}
export default async function ClientsPage() {
const clients = await getClients();
return <ClientsDataTable initialClients={clients} />;
}
typescript
const ClientsDataTable = ({ initialClients }: ClientsDataTableProps) => {
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 5;
const filteredClients = useMemo(() =>
initialClients.filter(client =>
client.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
client.website.toLowerCase().includes(searchQuery.toLowerCase())
), [initialClients, searchQuery]);
const totalPages = Math.ceil(filteredClients.length / itemsPerPage);
const paginatedClients = useMemo(() =>
filteredClients.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage),
[filteredClients, currentPage, itemsPerPage]
);
....
I remember you can make the req.json() return asynchronous.
async function getClients(): Promise<ClientInterface[]> {
const res = await fetch("http://localhost:3000/api/clients");
if (!res.ok) throw new Error("Failed to fetch clients");
return await res.json();
}
it's a minor detail
@Losti! Great, are you still having problems retrieving the data, or not anymore?
Gulf menhadenOP
No everything is good now. Thanks for the help @Losti! @LuisLl .
@Losti! Oh, right, without a useEffect, the updated values wouldn't be changed.
Gulf menhadenOP
That was how I attempted it the first time, but it didnt help
const ClientsDataTable = () => {
const [clientCells, setClientCells] = useState<ClientInterface[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 5;
useEffect(() => {
const fetchClients = async () => {
const response = await fetch("/api/clients");
const data = await response.json();
setClientCells(data);
};
fetchClients();
}, []);
...
@Gulf menhaden That was how I attempted it the first time, but it didnt help
typescript
const ClientsDataTable = () => {
const [clientCells, setClientCells] = useState<ClientInterface[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 5;
useEffect(() => {
const fetchClients = async () => {
const response = await fetch("/api/clients");
const data = await response.json();
setClientCells(data);
};
fetchClients();
}, []);
...
const ClientsDataTable = ({ initialClients }: ClientsDataTableProps) => {
const [clientCells, setClientCells] = useState<ClientInterface[]>(initialClients);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 5;
useEffect(() => {
setClientCells(initialClients);
}, [initialClients]);
...
Since you've made some changes and are now getting it from a server component, that should be enough.
If you don't want to delete the previous data with the new data, you could try something like:
const ClientsDataTable = ({ initialClients }: ClientsDataTableProps) => {
const [clientCells, setClientCells] = useState<ClientInterface[]>(initialClients);
const [searchQuery, setSearchQuery] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 5;
useEffect(() => {
setClientCells((prev) => {
const newClients = initialClients.filter(client =>
!prev.some(existingClient => existingClient.id === client.id)
);
return [
...prev,
newClients
]
};
}, [initialClients]);
...
I hope this helps and there is no unusual behavior.
American Chinchilla
Thanks guys i had a similiar post yesterday
It seems like the only option is to manually refresh using the router or use server components with the unstable cache
@Gulf menhaden make sure to mark the solution!