The Next.js App Router features a built-in client-side cache, known as the router cache.
The router cache is designed to make navigation between routes super efficient. When you visit a route in your Next.js app, the page content gets stored in the cache. If you revisit that same route later, the cached version will load instantly, resulting in a much smoother user experience.
The Example Application
Inside of router-cache-test
from the repo is an example App Router app that shows the current time on the homepage. The time updates every second, giving the impression of a live clock.
To test the cache, we'll try navigating from one route to another. First, we need to create a sub-route at app/sub-route/page.tsx
that links back to the homepage:
// inside app/sub-route/page.tsx
import Link from "next/link";
export default function SubRoute() {
return (
<main className="flex flex-col gap-3">
<Link href="/">Home</Link>
</main>
);
}
Then in the main page.tsx
file, we'll ad a link to the sub-route:
import Link from "next/link";
import Timer from "./Timer";
export default function Home() {
return (
<main>
<div>Time: {new Date().toLocaleTimeString()}</div>
<div>
<Link href="/sub-route">Sub-Route</Link>
</div>
<Timer />
</main>
);
}
If we build and run the app with pnpm build && pnpm start
, the homepage no longer updates the timer.
Examining the build output, we'll notice that both the homepage (/
) and the sub-route (/sub-route
) are marked as statically generated. This means they are pre-rendered at build time and served as static HTML files:
What we're seeing is the expected behavior of SSG, not a router cache issue. The caching is in the browser, not in the router.
Forcing Revalidation with Server Actions
In order to bust the cache in the browser, we need to use the router hook to refresh the page.
Inside of the sub-route/page.tsx
file, we can bring in the useRouter
hook from next/navigation
and create a button that calls router.refresh()
:
// inside app/sub-route/page.tsx
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button";
export default function SubRoute() {
const router = useRouter();
return (
<main className="flex flex-col gap-3">
<Link href="/">Home</Link>
<div>
<Button onClick={() => router.refresh()}>Bust The Cache</Button>
</div>
</main>
);
}
After we build and run the app again, we can navigate to the sub-route, click the "Bust The Cache" button, and then return to the homepage. However, the time is still stuck.
Again, this is not a router cache issue. The homepage route is static and won't rebuild unless it is revalidated.
Revalidating the Homepage
The way to fix this is with a server action. Create a new file revalidate-home.tsx
that revalidates the /
route:
// inside app/revalidate-home.tsx
"use server";
import { revalidatePath } from "next/cache";
export async function revalidateHome() {
revalidatePath("/");
}
We can then call this server action from our sub-route component, right before navigating back to the homepage:
// inside app/sub-route/page.tsx
"use client";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { revalidateHome } from "../revalidate-home";
export default function SubRoute() {
const router = useRouter();
return (
<main className="flex flex-col gap-3">
<div>
<Button
onClick={async () => {
await revalidateHome();
}}
>
Go Home
</Button>
</div>
</main>
);
}
Now, whenever we navigate back to the homepage from the sub-route, the server action will revalidate the route, ensuring that the timer is updated with the latest time!
It's also possible to add functionality that will revalidate the cache and then navigate back to the homepage. We can do this by calling router.push("/")
after revalidating the homepage:
// inside app/sub-route/page.tsx
return (
<main className="flex flex-col gap-3">
<div>
<Button
onClick={async () => {
await revalidateHome();
router.push('/');
}}
>
Go Home
</Button>
</div>
</main>
);
The Role of Response Headers in Revalidation
When a server action is executed, the Next.js client sends a request to the server with a special next-action-id
header. The server recognizes this header and executes the corresponding server action. If this action includes revalidatePath
or revalidateTag
, the server's response will include a x-nextjs-revalidate
header. This header tells the Next.js client to invalidate its cache for the specified route or tags, which will trigger a revalidation on the next navigation.
Handling Revalidation without Server Actions
What happens if we want to revalidate a route without directly calling a server action? For example, let's say we have an API route that modifies data used on the homepage.
Let's create an API route called revalidateHome
at app/api/revalidateHome/route.ts
that revalidates the homepage:
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
export function POST() {
revalidatePath("/");
return NextResponse.json({ message: "Home invalidated" });
}
We can then post to the API route from the sub-route component instead of calling the server action:
<Button
onClick={async () => {
await fetch("/api/revalidateHome", { method: "POST" });
// await revalidateHome();
router.push("/");
router.refresh();
}}
>
Go Home
</Button>
On-Demand Page Updates with router.refresh()
We can also use router.refresh()
to update the current page content on demand, without navigating to a different route. This is useful when we want to refresh the page after performing an action that might change the data being displayed.
Let's see how we can make our homepage dynamic and update it periodically using router.refresh()
.
First, we'll force the homepage to be dynamic by exporting a constant called dynamic
with the value "force-dynamic"
:
// inside app/page.tsx
// code as before
export const dynamic = "force-dynamic";
Then inside of the Timer
component, add a useEffect
that will call router.refresh()
every second:
// inside src/app/Timer.tsx
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export default function Timer() {
const router = useRouter();
useEffect(() => {
const timer = setInterval(() => {
router.refresh();
}, 1000);
return () => {
clearInterval(timer);
};
}, [router]);
return null;
}
Now, the homepage will refresh every second, requesting new content from the server, then merging it into the DOM to show the current time without flickering or needing to navigate away.
When to Worry About the Router Cache
The good news is that the router cache usually works seamlessly in the background. You'll typically only need to manually interact with it when working with API routes that modify data used on your pages or when you need fine-grained control over cache invalidation.