npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

next-cool-cache

v0.3.1

Published

Type-safe cache tag management for Next.js 16

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-cache

Quick 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 name

Refactoring 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 updating

Testing

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:

  1. Use as const on your schema and scopes
  2. Have strict: true in your tsconfig.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-types

What'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 2

This 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