How to Fix: Scroll to top after first time revalidatePath() happens
The page jumps back to the top after the first revalidatePath() because the refresh triggered by the server action causes the current route segment to be re-rendered, and the browser ends up treating that update like a navigation boundary instead of a fully preserved in-place mutation. In apps using the Next.js App Router, this usually shows up when a form submits, data is revalidated, and the UI tree above the scrolling container gets replaced on the first invalidation.
Understanding the Root Cause
This bug is typically not caused by your CSS or by a random browser quirk. It is usually the result of how server actions, revalidatePath(), and the App Router rendering lifecycle interact.
When you call revalidatePath() inside a server action, Next.js invalidates the cached data for that route so the page can fetch fresh data. After the action completes, the framework refreshes the route tree. If the refreshed subtree includes the component responsible for the current page layout, list rendering, or form boundary, React may reconstruct enough of the UI that the browser scroll position is not preserved during that first refresh.
Why only the first time? In many real-world cases, the first mutation changes the cache state, server payload, or route segment in a way that causes a more substantial rerender than later updates. After that initial refresh, subsequent invalidations may reuse more of the tree and appear stable.
Common technical triggers include:
- A form submission that causes a full route segment refresh.
- A parent Server Component being re-rendered and replacing the subtree that contains the scroll context.
- Mixing revalidatePath() with client-side state updates in a way that remounts list items.
- Using unstable React keys, which makes the DOM rebuild worse after revalidation.
- Redirecting, refreshing, or pushing navigation immediately after mutation.
In this issue, the practical fix is to avoid relying on route-level invalidation when a more targeted UI update will do, or to preserve scroll manually if revalidation is required.
Step-by-Step Solution
The safest solution is to keep the mutation on the server, but move the scroll-sensitive interaction into a Client Component and preserve the current scroll position before the action triggers revalidation.
Approach 1: Preserve scroll position manually around the action
Create a client wrapper for the action trigger:
'use client';
import { useTransition } from 'react';
export function ActionButton({ action, children }) {
const [isPending, startTransition] = useTransition();
async function handleAction() {
const scrollY = window.scrollY;
startTransition(async () => {
await action();
requestAnimationFrame(() => {
window.scrollTo({ top: scrollY, behavior: 'auto' });
});
});
}
return (
<button onClick={handleAction} disabled={isPending}>
{isPending ? 'Updating...' : children}
</button>
);
}
Then pass your server action into that client component:
import { ActionButton } from './ActionButton';
import { updateSomething } from '@/app/_actions/actions';
export default function RowActions() {
return <ActionButton action={updateSomething}>Confirm</ActionButton>;
}
This works because you capture the scroll position before the route refresh and restore it immediately after the UI settles.
Approach 2: Revalidate a smaller surface area
If your current code invalidates a broad path such as the entire page, try reducing the blast radius. For example, if the mutation only affects a bookings table, revalidate the exact route that owns that table rather than a higher-level layout.
'use server';
import { revalidatePath } from 'next/cache';
export async function updateBooking(id) {
// mutate database
revalidatePath('/dashboard/bookings');
}
If you are already targeting the page precisely, the next improvement is to replace route invalidation with revalidateTag() if your fetches are tag-based.
import { revalidateTag } from 'next/cache';
export async function updateBooking(id) {
// mutate database
revalidateTag('bookings');
}
And in your data fetch:
await fetch(apiUrl, {
next: { tags: ['bookings'] }
});
This often produces a less disruptive refresh because the cache invalidation becomes more data-focused and less route-focused.
Approach 3: Prevent remounts caused by unstable keys
If your list rows or cards use non-stable keys, React will rebuild the DOM after revalidation and scroll preservation becomes unreliable.
{bookings.map((booking) => (
<BookingRow key={booking.id} booking={booking} />
))}
Avoid this:
{bookings.map((booking, index) => (
<BookingRow key={index} booking={booking} />
))}
Approach 4: Keep the scrolling container stable
If your scroll is happening inside a custom container rather than the window, preserve that container and do not remount it on refresh.
'use client';
import { useRef } from 'react';
export function ScrollContainer({ children }) {
const ref = useRef(null);
return <div ref={ref} className="overflow-auto">{children}</div>;
}
If the container itself is recreated after revalidation, its internal scroll state will reset even if the window scroll does not.
Approach 5: Use optimistic UI when possible
If the user action updates a small part of the interface, consider an optimistic update on the client and let the server revalidation happen in the background. This reduces the perception of a hard rerender.
'use client';
import { useOptimistic, useTransition } from 'react';
export function BookingStatus({ booking, action }) {
const [isPending, startTransition] = useTransition();
const [optimisticStatus, setOptimisticStatus] = useOptimistic(booking.status);
function handleConfirm() {
startTransition(async () => {
setOptimisticStatus('confirmed');
await action();
});
}
return (
<button onClick={handleConfirm} disabled={isPending}>
{optimisticStatus}
</button>
);
}
Recommended fix order for this issue
- Check whether the action uses revalidatePath() on a page or layout that is too broad.
- Replace it with revalidateTag() if your data fetching supports tags.
- Ensure list items use stable keys.
- If the jump still happens, wrap the action trigger in a client component and restore scroll manually.
Common Edge Cases
- Redirect after mutation: If your server action also calls redirect(), the page jump is expected because navigation replaces the route state.
- Nested layouts: Revalidating a path owned by a parent layout can remount more of the tree than intended.
- Custom scroll containers: If the content scrolls inside a div instead of the window, saving window.scrollY will not help. You must save the container scroll position.
- Index-based keys: These can cause DOM replacement even when the data change is minimal.
- Suspense boundaries: A boundary that falls back during refresh can visually snap the page and make scroll restoration feel inconsistent.
- Focus management: After form submission, browser focus may move to the top of the page or to the first interactive element, which can appear like a scroll bug.
- Streaming updates: With streamed server component payloads, the timing of scroll restoration may need requestAnimationFrame() or even a short timeout if the subtree settles asynchronously.
FAQ
Does revalidatePath() always reset scroll?
No. It usually preserves scroll when the refreshed tree is stable. The jump happens when the invalidation causes enough of the route or scroll container to remount.
Is router.refresh() better than revalidatePath() for this bug?
Not necessarily. router.refresh() can produce the same visible behavior because it also refreshes the current route tree. The real question is whether you can reduce remounting or preserve scroll explicitly.
Should I switch everything to client-side fetching to avoid this?
No. Server rendering and server actions are still the right architecture for most dashboard pages. Start by narrowing cache invalidation, stabilizing keys, and restoring scroll only where needed.
If you want the most reliable fix for this specific issue, use a client wrapper around the action trigger, save the current scroll position, then restore it after the server action completes. Pair that with narrower invalidation such as revalidateTag() or a more precise revalidatePath() target so the App Router does less work during the refresh.