Caching in Nuxt server routes for Vue & Nuxt developers
By day 90 of building with Nuxt, you've likely hit the wall where your server routes are doing real work—querying databases, calling external APIs, computing expensive operations. Without intelligent caching, you're burning CPU cycles and API quotas on identical requests. This article walks you through caching in Nuxt server routes for Vue & Nuxt developers, showing how Nitro's built-in caching layer lets you serve responses faster while keeping your infrastructure costs down.
Why server-side caching matters in Nuxt
Client-side caching helps, but server-side caching is where production applications win. When ten users simultaneously request the same product details, you don't want to hit your database ten times. With Nuxt's Nitro runtime, you get powerful, declarative caching that works out of the box—no extra packages, no middleware boilerplate.
The difference is immediate: a cached response serves in milliseconds versus hundreds of milliseconds for database queries. Over millions of requests, that's the difference between a t3.medium and a t3.xlarge on AWS.
Understanding defineCachedEventHandler
defineCachedEventHandler is Nitro's primary caching primitive. It wraps your route handler and automatically caches the response based on a TTL (time-to-live) you specify.
// server/routes/api/products/[id].ts
export default defineCachedEventHandler(
async (event) => {
const id = getRouterParam(event, 'id');
const product = await db.products.findById(id);
return product;
},
{
maxAge: 60 * 60, // 1 hour in seconds
}
);
The first argument is your handler function—async or sync, doesn't matter. The second argument configures caching behavior. That handler will now cache responses for one hour. Subsequent requests within that window skip the database entirely.
This is perfect for slowly-changing data: product catalogs, category lists, user profiles that don't update constantly. For real-time data like stock levels or order statuses, you'd use shorter TTLs or skip caching altogether.
You can also nest defineCachedEventHandler alongside middleware:
// server/routes/api/categories.ts
export default defineCachedEventHandler(
async (event) => {
// Expensive category tree computation
const tree = await buildCategoryHierarchy();
return tree;
},
{
maxAge: 24 * 60 * 60, // 24 hours
stale: 60 * 60, // Serve stale for 1 hour past expiry
}
);
The stale option is subtle but powerful: if the cache expires but the server is busy, Nitro can serve slightly outdated data while refreshing in the background. This prevents cache stampedes when popular items expire simultaneously.
Cache keys per request
By default, Nitro caches based on the full request URL and method. But you often need granular control—cache the same handler differently depending on query parameters, headers, or user context.
Use the getKey callback to customize how Nitro builds the cache key:
// server/routes/api/search.ts
export default defineCachedEventHandler(
async (event) => {
const query = getQuery(event);
const results = await search(query.q, query.category);
return results;
},
{
maxAge: 5 * 60,
getKey: (event) => {
const query = getQuery(event);
// Cache separately per search term and category
return `search:${query.q}:${query.category || 'all'}`;
},
}
);
Now searching for "shoes" in the "men's" category generates a different cache key than "shoes" in "women's". Without this, you'd serve men's results to women's queries.
For authenticated endpoints, include the user ID in the key:
// server/routes/api/me/preferences.ts
export default defineCachedEventHandler(
async (event) => {
const user = await requireAuth(event);
const prefs = await db.userPreferences.findByUserId(user.id);
return prefs;
},
{
maxAge: 10 * 60,
getKey: (event) => {
const user = requireAuth(event);
return `user-prefs:${user.id}`;
},
}
);
This ensures Alice never sees Bob's preferences—each user gets their own cache partition. Without the getKey customization, Nitro would cache based on the URL, and the first user to hit the endpoint would poison the cache for everyone else.
Storage drivers like Redis
By default, Nitro caches in memory. For single-server deployments or development, that's fine. But if you're running multiple Nuxt instances behind a load balancer, in-memory caching creates a coherence nightmare: each instance has its own cache, so the same request might hit the database on server A but use the cache on server B.
Redis solves this. Configure it in nuxt.config.ts:
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
storage: {
redis: {
driver: 'redis',
// Connection details
url: process.env.REDIS_URL || 'redis://localhost:6379',
},
},
// Tell Nitro to use Redis as the cache store
cache: {
driver: 'redis',
options: {
url: process.env.REDIS_URL || 'redis://localhost:6379',
},
},
},
});
Now every instance writes to and reads from the same Redis instance. A cache hit on any server is a cache hit for all servers. This is essential for production deployments with horizontal scaling.
Redis also persists across restarts (with appropriate RDB or AOF settings), so you don't lose cached data during deployments.
If you're already using external storage—maybe S3 for static assets—you can use the same storage configuration:
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
cache: {
driver: 'fs',
options: {
base: '/tmp/nitro-cache', // Or mounted volume in containers
},
},
},
});
File-system caching works in single-instance or containerized setups where the cache directory is shared (via volumes). It's slower than Redis but requires no external service.
Invalidate on writes
Caching is useless if stale data lives forever. When a user updates a product, deletes a category, or places an order, you must invalidate related cache entries immediately.
Nitro provides clearCache in server utilities:
// server/routes/api/products/[id].ts
import { clearCache } from '#nitro/utils';
export default defineEventHandler(async (event) => {
if (event.method === 'PUT') {
const id = getRouterParam(event, 'id');
const body = await readBody(event);
const updated = await db.products.update(id, body);
// Clear the product details cache
await clearCache({ key: `product:${id}` });
return updated;
}
// GET handler remains cached
if (event.method === 'GET') {
const id = getRouterParam(event, 'id');
return db.products.findById(id);
}
});
When a PUT arrives, you invalidate the cache before returning. Subsequent GETs fetch fresh data from the database.
For bulk operations, clear multiple entries:
// server/routes/api/admin/bulk-update.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event);
const updated = await db.products.updateMany(body.ids, body.changes);
// Clear cache for all affected products
for (const id of body.ids) {
await clearCache({ key: `product:${id}` });
}
// Also clear list caches that might include these products
await clearCache({ key: 'products-list' });
await clearCache({ key: 'search:*' }); // Pattern matching
return { success: true, count: updated.length };
});
Pattern-based clearing (key: 'search:*') invalidates all search results at once. This is handy when a global data change (like a price update) affects many cached queries.
Be thoughtful about what you invalidate. Invalidating too aggressively defeats caching; invalidating too conservatively serves stale data. A good heuristic: invalidate only what changed, as narrowly as possible.
Common mistakes
- Forgetting to customize cache keys for authenticated routes: Default URL-based keys leak data between users. Always include user IDs or session tokens in
getKeywhen handling personal data. - Caching non-deterministic responses: If your handler uses random values, timestamps, or side effects (like sending emails), caching will repeat them. Cache only pure computations or database fetches.
- Not invalidating on writes: The most common production bug. Cache hits persist even after updates. Pair every write with a
clearCachecall. - Using in-memory caching with multiple instances: Each instance caches separately, so you get incoherent results. Use Redis or a shared storage driver in distributed setups.
- Setting TTLs too high: One day of caching might seem efficient, but a bug fix or data correction takes 24 hours to propagate. Start conservative (minutes to hours), increase only for truly static data.
- Ignoring cache memory limits: If your cache grows unbounded, it evicts old entries unpredictably. Set appropriate
maxmemorypolicies in Redis, or use cache warming to populate only necessary data.
Key takeaways
defineCachedEventHandleradds automatic response caching with a simple declarative API—just wrap your handler and set a TTL.- Customize cache keys with
getKeyto partition by query parameters, user ID, or other context, preventing data leaks and incoherent caching. - Redis-backed caching ensures consistency across multiple instances in production; in-memory caching suffices for single-instance or development setups.
- Always pair write operations (PUT, POST, DELETE) with
clearCachecalls to prevent serving outdated data. - Start with modest TTLs (minutes to hours) and increase only for truly static data; aggressive caching is a production liability if invalidation logic is incomplete.






Comments
Loading comments…