Introduction
Caching is one of the most critical aspects of web performance, ensuring fast response times while reducing unnecessary backend load. Next.js, with its flexible data-fetching strategies, offers a robust caching system that can be fine-tuned for different use cases.
Recently, I experimented with Next.js’s caching mechanisms, particularly the cache() API, and explored how it interacts with data-fetching strategies like ISR, SSR, and streaming in server components.
In this post, I’ll share a deep dive into how Next.js handles caching, how to optimize its behavior, and some of the pitfalls I encountered along the way.
Understanding Next.js Caching
Next.js has a layered caching approach that determines whether a request should be served from cache or trigger new data fetching. These layers include:
1. Build-Time Caching (SSG & ISR)
When using Static Site Generation (SSG), Next.js pre-renders pages at build time and caches them indefinitely unless revalidated.
• Pages built with getStaticProps in older Next.js versions behave this way.
• In the App Router (app/ directory), static pages are cached automatically unless fetch() is explicitly configured otherwise.
Incremental Static Regeneration (ISR) allows these static pages to update dynamically while still leveraging cache:
export async function GET() { const res = await fetch('https://api.example.com/data', { next: { revalidate: 60 }, // Revalidate cache every 60 seconds }); return Response.json(await res.json()); }
Here, Next.js will serve cached data until 60 seconds have passed, after which it fetches fresh data.
2. Per-Request Caching (SSR & Server Components)
For Server-Side Rendering (SSR), pages are generated on every request unless explicitly cached.
With the App Router, caching can be controlled at the fetch level:
export async function GET() { const res = await fetch('https://api.example.com/data', { cache: 'no-store', // Disables caching }); return Response.json(await res.json()); }
This guarantees fresh data on every request but increases load on the backend.
3. Function-Level Caching (cache())
The cache() API in Next.js provides an additional layer of control, allowing you to cache function results across multiple calls within the same request lifecycle:
import { cache } from 'next/cache'; const getData = cache(async (id: string) => { const res = await fetch(`https://api.example.com/data/${id}`); return res.json(); }); export async function GET(req: Request) { const data = await getData('123'); // This will be cached for this request return Response.json(data); }
By using cache(), we can avoid multiple redundant API calls for the same request.
Customizing Cache Behavior
1. Setting Up Fine-Grained Cache Control
Next.js fetch requests support multiple caching strategies. Here’s how they differ:
| Cache Mode | Description | Use Case |
|---|---|---|
force-cache | Uses cached data even if stale | Static pages |
no-store | Always fetches fresh data | Live updates, authenticated requests |
revalidate: X | Stale-while-revalidate mechanism | News feeds, dashboards |
cache: 'default' | Uses Next.js defaults based on the rendering method | General use |
Example: Using force-cache to store expensive API requests in cache:
const response = await fetch('https://api.example.com/stats', { cache: 'force-cache', });
This ensures that repeated API calls within a short period won’t hit the backend.
2. Revalidating Data Dynamically
While revalidate: X automatically refreshes data at set intervals, sometimes you need manual cache invalidation. Next.js provides a way to do this via On-Demand Revalidation:
import { revalidatePath } from 'next/cache'; export async function POST(req: Request) { const { path } = await req.json(); revalidatePath(path); // Clears cache for a specific path return Response.json({ success: true }); }
This is useful for CMS integrations where content updates need to be reflected instantly.
Caching in Server Components
Server Components in Next.js naturally cache fetch requests unless cache: 'no-store' is specified. However, when streaming large amounts of data, cache handling becomes crucial to avoid delays.
Example: Fetching and streaming a large dataset efficiently with caching enabled:
export default async function Page() { const data = await fetch('https://api.example.com/streamed-data', { next: { revalidate: 300 }, }).then((res) => res.json()); return ( <div> {data.map((item) => ( <p key={item.id}>{item.name}</p> ))} </div> ); }
Since this is a Server Component, the data will be cached and only refreshed every 5 minutes.
Challenges and Pitfalls
1. Conflicts Between SSR & ISR
When mixing no-store fetch requests inside an ISR page, caching can become inconsistent. If a page is statically generated but makes an SSR request inside a Server Component, it can cause hydration mismatches.
- Fix: Use
revalidate: Xinstead ofno-storefor better consistency.
2. Unexpected Cache Stale Issues
When caching API calls, certain headers (like authentication tokens) can prevent cache reuse.
- Fix: Always set
cache: 'no-store'for private data.
const response = await fetch('https://api.example.com/user', { headers: { Authorization: `Bearer ${token}` }, cache: 'no-store', });
3. Over-Caching Can Lead to Outdated Data
If you overuse force-cache or long revalidation periods, users might see outdated information.
- Fix: Use
revalidatePath()in API routes to refresh data dynamically.
Final Thoughts
After experimenting with Next.js’s cache API, I’ve found that:
- ✅ Using cache() can optimize repeated function calls within the same request.
- ✅ Revalidate strategies (revalidate: X) provide a balance between performance and fresh data.
- ✅ Manual cache invalidation (revalidatePath) is crucial for real-time updates.
- ✅ Avoid mixing SSR and ISR fetch behaviors to prevent caching conflicts.
Caching in Next.js is incredibly powerful, but it requires careful tuning depending on your use case.
Whether you’re building a statically optimized site or a real-time dashboard, understanding Next.js caching deeply can help you make better architectural decisions.