Next.js Client Navigation Stays Stuck on a Stale Page After `router.refresh()`

app-router, cache, navigation, nextjs, react

router.refresh() completes without an error, but the client still shows the old page content after navigation. There is no React runtime exception, no Next.js overlay, and no visible network failure; the page simply stays stale even though the refresh request was sent.

What router.refresh() actually invalidates

In the Next.js App Router, router.refresh() is a client-side navigation primitive from next/navigation. It asks Next.js to refetch the current route’s server component payload and merge the new result into the existing React tree.

It does not mean “reset the whole page.”

What gets invalidated is the server-rendered data for the current route segment tree. Next.js will request a new React Server Components payload, re-run layout.tsx, page.tsx, and any server components under the active route, then patch the result into the client tree.

What does not automatically reset:

If a client component remains mounted and continues to render the old value from local state, the refreshed server payload can arrive correctly while the visible UI stays stale.

Why the page can stay stale after a refresh

The stale display usually comes from one of three mechanisms.

1. Client component state still owns the visible value

A server component can pass updated props, but a client component may have copied those props into local state once and then stopped following later updates.

tsx
'use client'; import { useEffect, useState } from 'react'; type Props = { title: string; }; export function EditableTitle({ title }: Props) { const [value, setValue] = useState(title); return ( <input value={value} onChange={(e) => setValue(e.target.value)} /> ); }

If the server sends a new title after router.refresh(), useState(title) does not run again. The input keeps the old state because the component instance is still mounted.

This is the most common reason a refreshed page appears unchanged even though the server data changed.

2. The server component re-ran, but the data source was still cached

router.refresh() only asks for a new server payload. It does not automatically purge every cache involved in producing that payload.

If the page reads from fetch() with default caching, the response may still come from Next.js cache. In the App Router, fetch() is cached by default in many server contexts unless you opt out with cache: 'no-store' or use revalidation.

tsx
export default async function Page() { const res = await fetch('https://example.com/api/post/123'); const post = await res.json(); return <pre>{JSON.stringify(post, null, 2)}</pre>; }

That fetch() can remain cached. router.refresh() re-renders the page, but the page can re-render the same cached payload.

3. The component tree is preserved by React reconciliation

router.refresh() updates server components, but React tries to preserve mounted client components where possible. If the route tree shape stays compatible, React may keep the client component instance alive, along with its state and memoized values.

That means a client component that derives display content from stale state, stale memoization, or a stale context value can keep rendering the old output.

What rerenders and what does not

It helps to separate the App Router pieces.

Server components rerender

These are recomputed on the server when the refresh request arrives:

If the data source changes and is not cached, the refreshed server payload should contain the new data.

Client components may rerender, but state survives

A client component can receive new props and rerender, but this is not the same as remounting.

Preserved values include:

Browser cache and fetch cache are separate concerns

A network response, a Next.js fetch() cache hit, and a React state update are different layers.

router.refresh() only guarantees a new attempt to read the route’s server payload. It does not guarantee:

A minimal stale-state example

This page can stay stale after refresh even when the server data changes.

tsx
// app/posts/[id]/page.tsx import { PostEditor } from './post-editor'; async function getPost(id: string) { const res = await fetch(`http://localhost:3000/api/posts/${id}`, { cache: 'no-store', }); if (!res.ok) throw new Error('Failed to load post'); return res.json() as Promise<{ title: string }>; } export default async function Page({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; const post = await getPost(id); return <PostEditor initialTitle={post.title} />; }
tsx
// app/posts/[id]/post-editor.tsx 'use client'; import { useState } from 'react'; export function PostEditor({ initialTitle, }: { initialTitle: string; }) { const [title, setTitle] = useState(initialTitle); return ( <input value={title} onChange={(e) => setTitle(e.target.value)} /> ); }

Even if getPost() returns a different title after router.refresh(), PostEditor keeps the earlier state. useState(initialTitle) only uses the prop on the first mount.

Fixing stale client state

If the client component should follow server data, do not copy the prop into permanent local state unless that state is supposed to become independent.

Prefer controlled rendering from props

If the field is read-only, render the prop directly.

tsx
'use client'; export function PostTitle({ title }: { title: string }) { return <h1>{title}</h1>; }

Sync state when the prop changes

If local editing is required, sync state from props with useEffect.

tsx
'use client'; import { useEffect, useState } from 'react'; export function PostEditor({ initialTitle, }: { initialTitle: string; }) { const [title, setTitle] = useState(initialTitle); useEffect(() => { setTitle(initialTitle); }, [initialTitle]); return ( <input value={title} onChange={(e) => setTitle(e.target.value)} /> ); }

This is appropriate when the server is the source of truth and refresh should replace the current draft state.

Force a remount when you need a full reset

Sometimes the cleanest fix is to make the client component remount when the server record changes. Use a key that changes with the data identity.

tsx
export default async function Page({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; const post = await getPost(id); return <PostEditor key={post.updatedAt} initialTitle={post.title} />; }

A changing key forces React to discard the old instance and create a new one. That resets useState, useRef, and memoized values.

Use this carefully. It is useful when the UI should reset entirely, not when you need to preserve user input.

Fixing cached server data

If the server component keeps returning the old result, the client can refresh forever and still see stale output.

Use cache: 'no-store' for always-fresh reads

For data that must always reflect the latest server state, disable caching on the fetch() call.

tsx
async function getPost(id: string) { const res = await fetch(`https://example.com/api/posts/${id}`, { cache: 'no-store', }); return res.json(); }

This prevents Next.js from reusing the response cache for that request.

Use next: { revalidate } for time-based freshness

If exact real-time freshness is not required, set a revalidation interval.

tsx
await fetch(`https://example.com/api/posts/${id}`, { next: { revalidate: 10 }, });

This caches the data, then revalidates it after 10 seconds.

Use cache tags for targeted invalidation

For mutable content, cache tags are usually the best fit. Tag the read, then invalidate that tag after the mutation.

tsx
// reading await fetch(`https://example.com/api/posts/${id}`, { next: { tags: [`post:${id}`] }, });

Then invalidate from a server action or route handler.

tsx
'use server'; import { revalidateTag } from 'next/cache'; export async function updatePost(id: string, title: string) { await fetch(`https://example.com/api/posts/${id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title }), }); revalidateTag(`post:${id}`); }

This is more precise than broad route invalidation because it targets the underlying data cache.

How router.refresh() fits with server invalidation

router.refresh() and revalidateTag() solve different problems.

If the page uses cached data, router.refresh() alone may still render the old response. If the page uses live data but the client component stores a copy in state, revalidateTag() alone may still not update the visible UI.

A complete update usually needs both layers aligned:

  1. Invalidate the data cache on the server.
  2. Refresh the route on the client.
  3. Ensure the client component either follows props or remounts.

A complete mutation flow

A common pattern is:

tsx
// app/posts/[id]/actions.ts 'use server'; import { revalidateTag } from 'next/cache'; export async function renamePost(id: string, title: string) { await fetch(`https://example.com/api/posts/${id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title }), }); revalidateTag(`post:${id}`); }
tsx
'use client'; import { useRouter } from 'next/navigation'; import { useTransition } from 'react'; import { renamePost } from './actions'; export function RenameButton({ id }: { id: string }) { const router = useRouter(); const [pending, startTransition] = useTransition(); return ( <button disabled={pending} onClick={() => startTransition(async () => { await renamePost(id, 'New title'); router.refresh(); }) } > Rename </button> ); }

This is effective only if the page display is not trapped in stale client state. If the title is shown from useState(initialTitle), the refreshed prop still won’t replace that state unless you sync it or remount the component.

Debugging the stale page

When a page stays stale after router.refresh(), check the layers in this order.

Verify the server payload changed

Inspect the data source directly in the page’s server code. Add a temporary console.log() in the server component or route handler and confirm the server receives new data.

If the server logs show fresh data but the UI does not change, the problem is client state or memoization.

Verify the fetch is not cached

For fetch() on the server, check whether the request uses default caching.

Use one of these depending on the requirement:

Check whether the displayed value comes from local state

Search for:

Check whether the component should remount

If the entire record changed and the UI should reset fully, use a key based on the record identity or version.

tsx
<PostEditor key={post.id + ':' + post.version} initialTitle={post.title} />

That is often the simplest way to eliminate preserved state.

Which fix to prefer

Prefer the narrowest fix that matches the data flow.

Use revalidateTag() when the underlying problem is stale server cache for a specific entity or collection. It is the most precise server-side invalidation mechanism for App Router data.

Use cache: 'no-store' when the data must always be fresh and caching provides no benefit.

Use useEffect synchronization when the client component should stay mounted but follow updated props.

Use a changing key when the component should reset entirely on new data.

Use router.refresh() as the client trigger that re-requests the current route payload. It is not a full reset, and it does not override client state or cached data by itself.

The stale page usually comes from relying on router.refresh() to do all three jobs at once: invalidate server data, replace client state, and force a remount. It only performs the first part.