Caching an API response can turn repeated database work into a fast lookup, reduce infrastructure load, and improve latency for users around the world. It can also serve stale data, leak personalized responses, or produce confusing behavior if ownership and invalidation are unclear.
For Next.js applications, the key is to treat caching as a system rather than a single setting. A request might pass through a browser cache, a shared CDN cache, a Next.js server, and an application cache before reaching the database. Each layer has different semantics and failure modes.
This article presents a practical strategy for caching Next.js API routes and App Router Route Handlers without relying on implicit framework defaults.
Start with the shape of the data
Before adding a cache, classify the endpoint. The right policy depends more on the data than on whether the code lives in pages/api or app/api.
Ask four questions:
- Is the response public or user-specific? Product catalogs may be public; account balances are not.
- How quickly can it become stale? A country list might tolerate hours of staleness, while inventory may tolerate only seconds.
- What triggers a change? Data may change on a schedule, through an administrative action, or after every write.
- What is the cost of regeneration? A database aggregation deserves more protection than a constant configuration object.
A useful initial classification looks like this:
| Endpoint type | Typical policy | Primary risk |
|---|---|---|
| Public, slow-changing data | Shared CDN cache with revalidation | Serving outdated content |
| Public, frequently updated data | Short shared TTL and stale-while-revalidate | Temporary inconsistency |
| Personalized data | Private cache or no-store | Cross-user data exposure |
| Expensive derived data | Server-side cache with explicit invalidation | Invalidation complexity |
| Mutation endpoint | Usually no-store | Replaying or caching a write response |
Do not make a response publicly cacheable merely because it uses GET. Authorization, cookies, tenant identity, locale, and feature flags can all make a GET response user-specific.
Understand the available cache layers
A typical request can encounter several independent caches:
flowchart LR
U[Client] --> B[Browser cache]
B --> C[CDN cache]
C --> N[Next.js route]
N --> A[Application cache]
A --> D[Database or upstream API]
Browser cache
The browser cache is private to a user agent. The max-age directive controls how long the browser may reuse a response without contacting the server.
Browser caching is valuable for immutable or non-sensitive resources, but long browser TTLs are difficult to revoke. If a client must observe changes promptly, prefer a short max-age even when the CDN is allowed to cache longer.
Shared CDN or proxy cache
A shared cache can serve one response to many users. s-maxage controls freshness in caches that understand shared-cache directives, while stale-while-revalidate can permit a stale response during background refresh.
This layer usually provides the largest latency and origin-load reduction for public APIs. It is also the most dangerous place to cache a personalized response.
Application cache
An application cache stores computed values inside or alongside the server. Examples include a bounded in-memory cache for a long-running process or a shared external store used by multiple instances.
This layer is useful when a request still needs to reach the application—for authorization, composition, or protocol reasons—but expensive data retrieval should be avoided.
Upstream cache
Databases, search engines, and external services may have their own caching behavior. Treat that as a separate concern. An endpoint cache reduces requests reaching the upstream system; it does not guarantee that the upstream result itself is current.
Use explicit HTTP caching for public GET routes
For public responses, start with standards-based Cache-Control headers. Explicit policies are easier to inspect and reason about than framework defaults, which may vary by router and Next.js version.
Here is an App Router Route Handler with a short browser TTL and a longer shared-cache TTL:
// app/api/catalog/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
const products = await loadPublicCatalog();
return NextResponse.json(products, {
headers: {
'Cache-Control':
'public, max-age=30, s-maxage=300, stale-while-revalidate=60',
},
});
}
This policy communicates that:
- A browser may reuse the response for 30 seconds.
- A shared cache may consider it fresh for 300 seconds.
- A compatible shared cache may serve it stale for up to 60 additional seconds while revalidating.
stale-while-revalidate improves tail latency, but it deliberately permits temporary staleness. Do not use it where every read must immediately reflect the latest write.
The equivalent Pages Router API route sets the same header directly:
// pages/api/catalog.ts
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== 'GET') {
res.setHeader('Allow', 'GET');
return res.status(405).json({ error: 'Method not allowed' });
}
const products = await loadPublicCatalog();
res.setHeader(
'Cache-Control',
'public, max-age=30, s-maxage=300, stale-while-revalidate=60',
);
return res.status(200).json(products);
}
Whether a particular hosting platform actually stores the response depends on its CDN and proxy configuration. Headers express the policy; deployment behavior should be verified with integration tests and response headers from the production environment.
Protect personalized and sensitive responses
An authenticated route should normally prevent shared caching. A conservative policy is:
return NextResponse.json(profile, {
headers: {
'Cache-Control': 'private, no-store',
},
});
private says a shared cache must not store the response. no-store says caches should not store it at all. Using both is intentionally defensive for sensitive data.
Avoid trying to rescue public caching with Vary: Cookie or Vary: Authorization. Although Vary can distinguish representations by request headers, cookies and authorization values often create extremely high-cardinality cache keys. That reduces cache effectiveness and increases the consequences of a configuration mistake.
A safer architecture separates public and private resources:
/api/productsreturns a public catalog./api/me/pricesreturns customer-specific pricing withno-store.- The client or server combines them after authorization.
Also avoid public caching when the output varies by tenant, experiment assignment, or permission unless the cache key explicitly includes that dimension and the isolation model has been carefully reviewed.
Add server-side caching for expensive work
HTTP caching avoids running the route when a browser or CDN has a fresh response. An application cache instead avoids repeated database or upstream work after the request reaches the route.
The cache key must include every input that can change the result:
function catalogCacheKey(input: {
tenantId: string;
locale: string;
currency: string;
}) {
return [
'catalog',
input.tenantId,
input.locale,
input.currency,
].join(':');
}
Leaving tenantId out of this key could expose one tenant's data to another. Leaving out locale or currency may be less severe, but still produces incorrect responses.
A cache-aside flow is straightforward:
sequenceDiagram
participant R as Route
participant C as Cache
participant D as Database
R->>C: Get cache key
alt Cache hit
C-->>R: Cached value
else Cache miss
R->>D: Query data
D-->>R: Result
R->>C: Store value with TTL
end
R-->>R: Build response
The following example uses an application-defined cache interface. Its implementation could be an external shared cache or another store appropriate for the deployment:
type Cache = {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttlSeconds: number): Promise<void>;
};
async function getCatalog(
cache: Cache,
input: { tenantId: string; locale: string; currency: string },
) {
const key = catalogCacheKey(input);
const cached = await cache.get<Catalog>(key);
if (cached !== null) return cached;
const catalog = await queryCatalog(input);
await cache.set(key, catalog, 300);
return catalog;
}
This snippet intentionally describes an interface rather than assuming a specific vendor. In production, define serialization, maximum item size, connection timeouts, error handling, and key versioning.
An in-memory Map is usually unsuitable as the only production cache. It is unbounded unless managed carefully, disappears on restart, and is not shared across server processes or serverless instances. It can still be useful for small best-effort caches in a known long-running runtime, but correctness must never depend on it.
Choose an invalidation strategy
A cache is useful only if the application has a clear answer to: “When does this value stop being valid?”
Time-based expiration
A TTL is the simplest option. It works well when bounded staleness is acceptable and updates are frequent enough that tracking each one would add unnecessary complexity.
Choose the TTL from a business requirement, not intuition. “Prices may be stale for at most one minute” is actionable; “cache prices for a while” is not.
Event-driven invalidation
When a known write changes cached data, delete or replace the corresponding keys after the write succeeds:
await updateProduct(productId, changes);
await cache.delete(`product:${productId}`);
await cache.delete(`catalog:${tenantId}:${locale}:${currency}`);
The hard part is identifying every derived key affected by the mutation. Broad invalidation is easier but reduces hit rate. Precise invalidation performs better but creates more dependency bookkeeping.
There can also be a failure between the database update and cache deletion. Systems requiring stronger guarantees may use a transactional outbox or change event so invalidation can be retried. For less critical data, a short fallback TTL may be sufficient.
Versioned keys
Including a schema or data version in keys avoids collisions after response formats change:
const key = `catalog:v2:${tenantId}:${locale}:${currency}`;
Versioning does not immediately remove old entries, but they become unreachable and expire naturally. This is often safer than trying to coordinate a full cache flush during deployment.
Prevent cache stampedes
When a popular key expires, many concurrent requests may all miss and regenerate the same value. This is known as a cache stampede.
Possible mitigations include:
- Serving stale data while one request refreshes it.
- Using a distributed lock or single-flight mechanism per key.
- Adding small random jitter to TTLs so related keys do not expire simultaneously.
- Refreshing high-traffic keys shortly before expiration.
Locks require careful timeout and failure handling. A stale-while-revalidate design is usually simpler when brief staleness is acceptable. For strongly consistent data, it may be better to accept more origin work than introduce an unreliable locking scheme.
Use conditional requests when validation is cheap
ETag or Last-Modified headers allow clients to ask whether a previous representation is still current. If it is, the server can return 304 Not Modified without sending the body.
Conditional requests save bandwidth, but they do not automatically save database work. If the server must regenerate the complete response to compute its ETag, the origin cost remains. They are most effective when a version number or modification timestamp can be checked cheaply.
An ETag must describe the exact representation. If JSON output differs by locale, permissions, or encoding, the validator and relevant Vary headers must reflect those differences.
Test behavior at the boundaries
Caching bugs often appear only in deployed infrastructure. Test both route logic and end-to-end behavior.
At minimum, verify:
- Public routes return the intended
Cache-Controldirectives. - Personalized routes return
private, no-store. - Different tenants, users, locales, and currencies cannot share incorrect values.
- A cache miss and a cache hit return equivalent payloads.
- Writes invalidate or outlive cached entries as designed.
- Cache outages degrade safely, usually by falling back to the source with bounded timeouts.
- Production responses show expected cache status and age behavior where the platform exposes those headers.
Monitor hit ratio, miss latency, regeneration errors, stale responses, and cache-store failures. A high hit ratio is not inherently good if the cache is serving incorrect data, so pair performance metrics with freshness and correctness checks.
A practical default strategy
For many Next.js applications, a safe starting point is:
- Use explicit HTTP cache headers for public GET responses.
- Use
private, no-storefor authenticated or sensitive responses. - Add a shared application cache only around demonstrably expensive operations.
- Build cache keys from every response-shaping input.
- Start with short TTLs, then lengthen them using observed traffic and freshness requirements.
- Add event-driven invalidation only where stale data has meaningful business impact.
- Verify behavior in the actual deployment environment rather than assuming a header guarantees CDN storage.
Conclusion
Effective caching for Next.js API routes begins with data classification, not code. Public responses can benefit from explicit browser and CDN policies, while personalized responses should default to private, non-stored handling. Expensive computations may justify an application cache, provided keys, expiration, invalidation, and failure behavior are designed deliberately.
Keep the first implementation simple, measure it in production, and treat cache correctness as part of the API contract. A cache should make a correct system faster—not become a second, less predictable source of truth.

