next-cool-cache
v0.3.1
Published
Type-safe cache tag management for Next.js 16
Maintainers
Readme
next-cool-cache
Type-safe cache tag management for Next.js 16+. Stop wrestling with string-based cache tags and let TypeScript catch your mistakes at compile time.
Why?
Next.js 16 introduces powerful cache primitives (cacheTag, revalidateTag, updateTag), but they're all string-based. This leads to real problems:
Problem 1: Silent Typos Break Your Cache
// ❌ WITHOUT next-cool-cache - A typo that ruins your day
async function getUser(id: string) {
'use cache: remote';
cacheTag(`users/byId:${id}`); // Tag: "users/byId:123"
return db.users.findById(id);
}
async function updateUser(id: string, data: UserData) {
await db.users.update(id, data);
// Oops! "user" instead of "users" - spot the bug?
revalidateTag(`user/byId:${id}`); // Tag: "user/byId:123"
// ❌ Silent failure! Cache never invalidates.
// Users see stale data. No error thrown. Good luck debugging.
}// ✅ WITH next-cool-cache - TypeScript has your back
cache.admin.users.byId.cacheTag({ id }); // Autocomplete guides you
cache.admin.users.byId.revalidateTag({ id }); // Always matches
// cache.admin.user.byId.revalidateTag({ id })
// ❌ Compile error: Property 'user' does not exist. Did you mean 'users'?Problem 2: Different Users Need Different Cache Strategies
Imagine you're building a blog with drafts and published posts. When an admin edits a post:
- Admins need to see changes immediately (they're actively editing)
- Public users should never see a loading screen (use stale-while-revalidate)
Without scoped caching, you'd need to manage this manually with different tag prefixes and hope you get it right everywhere.
// ❌ WITHOUT next-cool-cache - Manual scope management
async function updateBlogPost(postId: string, data: BlogPostData) {
await db.posts.update(postId, data);
// Admin sees changes immediately
updateTag(`admin/blog/posts/byId:${postId}`);
// Public users get stale-while-revalidate (no loading screen)
revalidateTag(`public/blog/posts/byId:${postId}`);
// Did I use the right prefix? Is it "admin" or "admin-panel"?
// Is the path "blog/posts" or "posts"? Who knows!
}// ✅ WITH next-cool-cache - Scopes are first-class citizens
async function updateBlogPost(postId: string, data: BlogPostData) {
await db.posts.update(postId, data);
// Admin sees changes immediately (updateTag = expire now, fetch fresh)
cache.admin.blog.posts.byId.updateTag({ id: postId });
// Public users get stale-while-revalidate
// Old content shows instantly while new content loads in background
// Anonymous users NEVER see a loading screen
cache.public.blog.posts.byId.revalidateTag({ id: postId });
}Installation
npm install next-cool-cache
# or
pnpm add next-cool-cache
# or
yarn add next-cool-cacheQuick Start
1. Define Your Schema
// lib/cache.ts
import { createCache } from 'next-cool-cache';
// Define your cache structure
const schema = {
users: {
list: {}, // No params needed
byId: { _params: ['id'] as const }, // Requires { id: string }
},
blog: {
posts: {
list: {},
byId: { _params: ['id'] as const },
byAuthor: { _params: ['authorId'] as const },
},
drafts: {
byId: { _params: ['id'] as const },
},
},
} as const;
// Define your scopes
const scopes = ['admin', 'public', 'user'] as const;
// Create the typed cache
export const cache = createCache(schema, scopes);2. Use in Your App
// In a cached function
async function getBlogPost(id: string) {
'use cache: remote';
cache.public.blog.posts.byId.cacheTag({ id });
// → cacheTag('public/blog/posts/byId:<id>', 'public/blog/posts', 'public/blog', 'public', 'blog/posts/byId:<id>', 'blog/posts', 'blog')
return db.posts.findById(id);
}
// When data changes
async function updateBlogPost(id: string, data: PostData) {
await db.posts.update(id, data);
cache.admin.blog.posts.byId.updateTag({ id }); // Immediate for admin
// → updateTag('admin/blog/posts/byId:<id>')
cache.public.blog.posts.byId.revalidateTag({ id }); // SWR for public
// → revalidateTag('public/blog/posts/byId:<id>', 'max')
}
// Invalidate entire sections
async function clearAllPosts() {
cache.blog.posts.revalidateTag(); // All posts, all scopes
// → revalidateTag('blog/posts', 'max')
}API Reference
createCache(schema, scopes)
Creates a typed cache object from your schema and scopes.
const cache = createCache(schema, scopes);Parameters:
schema- Your cache structure (see Schema Format below)scopes- Array of scope names like['admin', 'public']
Schema Format
const schema = {
// Leaf node without params - call methods with no arguments
config: {},
// Leaf node with params - methods require the specified params
users: {
byId: { _params: ['id'] as const },
byEmail: { _params: ['email'] as const },
},
// Branch nodes - nested objects without _params
blog: {
posts: {
list: {},
byId: { _params: ['id'] as const },
},
},
// Multiple params
comments: {
byPostAndUser: { _params: ['postId', 'userId'] as const },
},
} as const; // Don't forget 'as const'!Leaf Node Methods
Leaf nodes (endpoints in your cache tree) have three methods:
cacheTag(params?)
Register cache tags inside a 'use cache' function. Automatically registers hierarchical tags for parent invalidation.
async function getUser(id: string) {
'use cache: remote';
cache.admin.users.byId.cacheTag({ id });
// Registers: 'admin/users/byId:123', 'admin/users', 'admin', 'users/byId:123', 'users'
return db.users.findById(id);
}revalidateTag(params?)
Stale-while-revalidate invalidation. Serves stale content while fetching fresh data in the background. Users never see loading states.
cache.public.blog.posts.byId.revalidateTag({ id: '123' });updateTag(params?)
Immediate invalidation. Expires the cache entry now - next request will fetch fresh and may show a loading state.
cache.admin.blog.posts.byId.updateTag({ id: '123' });Branch Node Methods
Branch nodes (intermediate objects in your cache tree) can invalidate entire subtrees:
// Invalidate all posts for admin
cache.admin.blog.posts.revalidateTag();
// Invalidate entire blog section for admin
cache.admin.blog.revalidateTag();
// Invalidate everything in admin scope
cache.admin.revalidateTag();Cross-Scope Operations
Access resources without a scope prefix to invalidate across all scopes:
// Invalidate user 123 in ALL scopes (admin, public, user, etc.)
cache.users.byId.revalidateTag({ id: '123' });
// Invalidate all blog content in ALL scopes
cache.blog.revalidateTag();More Examples
Hierarchical Invalidation
The cache tree structure enables powerful invalidation patterns:
// Fine-grained: Single post
cache.admin.blog.posts.byId.revalidateTag({ id: '123' });
// Medium: All posts in admin scope
cache.admin.blog.posts.revalidateTag();
// Broad: Entire blog section for admin
cache.admin.blog.revalidateTag();
// Broadest: Everything in admin scope
cache.admin.revalidateTag();
// Cross-scope: All blog content for everyone
cache.blog.revalidateTag();Parameter Enforcement
TypeScript ensures you pass the right parameters:
// Schema
const schema = {
blog: {
posts: {
list: {}, // No params
byId: { _params: ['id'] as const }, // Requires { id }
}
}
} as const;
// Usage
cache.admin.blog.posts.list.cacheTag(); // ✅ No args needed
cache.admin.blog.posts.byId.cacheTag({ id: '1' }); // ✅ Correct
cache.admin.blog.posts.byId.cacheTag(); // ❌ Error: missing { id }
cache.admin.blog.posts.byId.cacheTag({ userId: '1' }); // ❌ Error: wrong param nameRefactoring Safety
When you rename resources, TypeScript shows every place that needs updating:
// Before: schema has 'posts'
cache.admin.blog.posts.byId.revalidateTag({ id });
// After: rename to 'articles' in schema
cache.admin.blog.posts.byId.revalidateTag({ id });
// ^^^^^ Error: Property 'posts' does not exist.
// Did you mean 'articles'?
// TypeScript guides you to every call site that needs updatingTesting
Mock the next/cache module in your tests:
import { jest } from '@jest/globals';
const mockCacheTag = jest.fn();
const mockRevalidateTag = jest.fn();
const mockUpdateTag = jest.fn();
jest.mock('next/cache', () => ({
cacheTag: (...args: unknown[]) => mockCacheTag(...args),
revalidateTag: (...args: unknown[]) => mockRevalidateTag(...args),
updateTag: (...args: unknown[]) => mockUpdateTag(...args),
}));
import { createCache } from 'next-cool-cache';
const cache = createCache(schema, scopes);
// Test your cache logic
cache.admin.users.byId.revalidateTag({ id: '123' });
expect(mockRevalidateTag).toHaveBeenCalledWith('admin/users/byId:123', 'max');Debugging with _path
Every node exposes its path for debugging:
console.log(cache.admin.users.byId._path); // 'admin/users/byId'
console.log(cache.admin.users._path); // 'admin/users'
console.log(cache.users._path); // 'users' (unscoped)TypeScript Setup
For full type inference, ensure you:
- Use
as conston your schema and scopes - Have
strict: truein yourtsconfig.json
// ✅ Correct - full type inference
const schema = { ... } as const;
const scopes = ['admin', 'public'] as const;
// ❌ Wrong - loses type information
const schema = { ... }; // Missing 'as const'
const scopes = ['admin', 'public']; // Missing 'as const'Development
This is a monorepo using Turborepo.
# Install dependencies
pnpm install
# Build all packages
pnpm build
# Run tests
pnpm test
# Type check
pnpm check-typesWhat's New in v0.2.0
Hierarchical Params (Branch-Level _params)
You can now define _params at any level in the schema tree, not just at leaf nodes. This enables powerful patterns like user-scoped caches where all resources under a user inherit the userId param.
const schema = {
userPrivateData: {
_params: ['userId'] as const, // Branch-level params!
myWorkspaces: {
list: {},
byId: { _params: ['workspaceId'] as const },
},
myProfile: {
detail: {},
},
},
} as const;
const cache = createCache(schema, ['admin', 'user'] as const);Key Behaviors
cacheTag requires ALL accumulated params:
// Requires userId (from branch) + workspaceId (from leaf)
cache.admin.userPrivateData.myWorkspaces.byId.cacheTag({
userId: 'u1',
workspaceId: 'w1'
});
// Tag: 'admin/userPrivateData:u1/myWorkspaces/byId:w1'
// Leaf without own params still requires branch params
cache.admin.userPrivateData.myProfile.detail.cacheTag({ userId: 'u1' });
// Tag: 'admin/userPrivateData:u1/myProfile/detail'revalidateTag and updateTag accept OPTIONAL params for flexible invalidation:
// Specific: invalidate workspace w1 for user u1
cache.admin.userPrivateData.myWorkspaces.byId.revalidateTag({
userId: 'u1',
workspaceId: 'w1'
});
// Tag: 'admin/userPrivateData:u1/myWorkspaces/byId:w1'
// Broader: invalidate ALL workspaces for user u1
cache.admin.userPrivateData.myWorkspaces.byId.revalidateTag({ userId: 'u1' });
// Tag: 'admin/userPrivateData:u1/myWorkspaces/byId'
// Cross-dimensional: invalidate workspace w1 across ALL users
cache.admin.userPrivateData.myWorkspaces.byId.revalidateTag({ workspaceId: 'w1' });
// Tag: 'admin/userPrivateData/myWorkspaces/byId:w1'
// Broadest: invalidate entire subtree
cache.admin.userPrivateData.myWorkspaces.byId.revalidateTag();
// Tag: 'admin/userPrivateData/myWorkspaces/byId'Tag Format: Embedded Params
Params are embedded at their respective path segments (not appended at the end):
userPrivateData:u1/myWorkspaces/byId:w1
^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^
userId at level 0 workspaceId at level 2This enables precise invalidation at any dimension of your cache hierarchy.
Multi-Level Params
You can have params at multiple branch levels:
const schema = {
tenant: {
_params: ['tenantId'] as const,
users: {
_params: ['userId'] as const,
profile: {},
workspaces: {
list: {},
byId: { _params: ['workspaceId'] as const },
},
},
},
} as const;
// Requires all 3 params
cache.admin.tenant.users.workspaces.byId.cacheTag({
tenantId: 't1',
userId: 'u1',
workspaceId: 'w1'
});
// Tag: 'admin/tenant:t1/users:u1/workspaces/byId:w1'Backward Compatibility
Existing schemas with params only at leaf nodes continue to work unchanged.
Requirements
- Next.js 16.0.0 or higher
- TypeScript 5.0 or higher (recommended)
License
MIT
