next-cool-cache
Scenarios

Blog CMS

Building a blog content management system with intelligent caching

Blog CMS Scenario

Learn how to implement caching for a blog content management system where editors need instant feedback while public readers enjoy fast, cached pages.

The Challenge

A blog CMS has different user types with different needs:

  • Editors: Create and edit posts, need to see changes immediately
  • Authors: Write their own posts, need to see their drafts
  • Public readers: View published content, should never see loading states

Each audience needs a different caching strategy.

Schema Design

// lib/cache.ts
import { createCache } from 'next-cool-cache';

const schema = {
  blog: {
    posts: {
      list: {},
      featured: {},
      byId: { _params: ['id'] as const },
      bySlug: { _params: ['slug'] as const },
      byAuthor: { _params: ['authorId'] as const },
      byCategory: { _params: ['categoryId'] as const },
    },
    drafts: {
      list: {},
      byId: { _params: ['id'] as const },
      byAuthor: { _params: ['authorId'] as const },
    },
    categories: {
      list: {},
      byId: { _params: ['id'] as const },
      bySlug: { _params: ['slug'] as const },
    },
    tags: {
      list: {},
      bySlug: { _params: ['slug'] as const },
    },
  },
  authors: {
    list: {},
    byId: { _params: ['id'] as const },
    byUsername: { _params: ['username'] as const },
  },
  comments: {
    byPost: { _params: ['postId'] as const },
    count: { _params: ['postId'] as const },
  },
} as const;

const scopes = ['admin', 'public', 'author'] as const;

export const cache = createCache(schema, scopes);

Implementation Patterns

Pattern 1: Fetching Published Posts

Public blog listing with aggressive caching:

// app/blog/page.tsx
import { cache } from '@/lib/cache';

async function getPublishedPosts() {
  'use cache: remote';
  cache.public.blog.posts.list.cacheTag();

  return db.posts.findMany({
    where: { status: 'published' },
    orderBy: { publishedAt: 'desc' },
    take: 20,
  });
}

async function getFeaturedPosts() {
  'use cache: remote';
  cache.public.blog.posts.featured.cacheTag();

  return db.posts.findMany({
    where: { status: 'published', featured: true },
    take: 5,
  });
}

export default async function BlogPage() {
  const [posts, featured] = await Promise.all([
    getPublishedPosts(),
    getFeaturedPosts(),
  ]);

  return (
    <div>
      <FeaturedPosts posts={featured} />
      <PostList posts={posts} />
    </div>
  );
}

Pattern 2: Single Post Page

// app/blog/[slug]/page.tsx
import { cache } from '@/lib/cache';

async function getPostBySlug(slug: string) {
  'use cache: remote';
  cache.public.blog.posts.bySlug.cacheTag({ slug });

  return db.posts.findUnique({
    where: { slug, status: 'published' },
    include: { author: true, category: true },
  });
}

async function getComments(postId: string) {
  'use cache: remote';
  cache.public.comments.byPost.cacheTag({ postId });

  return db.comments.findMany({
    where: { postId, approved: true },
    orderBy: { createdAt: 'desc' },
  });
}

export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await getPostBySlug(params.slug);
  if (!post) notFound();

  const comments = await getComments(post.id);

  return (
    <article>
      <PostContent post={post} />
      <CommentSection comments={comments} />
    </article>
  );
}

Pattern 3: Publishing a Draft

When an editor publishes a draft, multiple caches need updating:

// app/actions/posts.ts
'use server';

import { cache } from '@/lib/cache';

export async function publishPost(postId: string) {
  const post = await db.posts.update({
    where: { id: postId },
    data: {
      status: 'published',
      publishedAt: new Date(),
    },
  });

  // Editor sees changes immediately
  cache.admin.blog.posts.byId.updateTag({ id: postId });
  cache.admin.blog.drafts.list.updateTag();
  cache.admin.blog.posts.list.updateTag();

  // Author sees their post is published
  cache.author.blog.drafts.byAuthor.updateTag({ authorId: post.authorId });
  cache.author.blog.posts.byAuthor.updateTag({ authorId: post.authorId });

  // Public gets stale-while-revalidate
  cache.public.blog.posts.list.revalidateTag();
  cache.public.blog.posts.featured.revalidateTag();
  cache.public.blog.posts.bySlug.revalidateTag({ slug: post.slug });
  cache.public.blog.posts.byCategory.revalidateTag({ categoryId: post.categoryId });

  return post;
}

Pattern 4: Editing a Post

// app/actions/posts.ts
'use server';

export async function updatePost(postId: string, data: PostUpdateData) {
  const post = await db.posts.update({
    where: { id: postId },
    data,
  });

  // Editor sees immediately
  cache.admin.blog.posts.byId.updateTag({ id: postId });

  // If published, update public caches
  if (post.status === 'published') {
    cache.public.blog.posts.byId.revalidateTag({ id: postId });
    cache.public.blog.posts.bySlug.revalidateTag({ slug: post.slug });

    // If title/excerpt changed, lists need updating
    if (data.title || data.excerpt) {
      cache.public.blog.posts.list.revalidateTag();
    }

    // If category changed, category pages need updating
    if (data.categoryId) {
      cache.public.blog.posts.byCategory.revalidateTag({ categoryId: data.categoryId });
    }
  }

  return post;
}

Pattern 5: Bulk Operations

Unpublishing all posts by an author (e.g., when author is banned):

// app/actions/authors.ts
'use server';

export async function banAuthor(authorId: string) {
  // Unpublish all their posts
  await db.posts.updateMany({
    where: { authorId },
    data: { status: 'draft' },
  });

  // Delete their comments
  await db.comments.deleteMany({
    where: { authorId },
  });

  // Mark author as banned
  await db.authors.update({
    where: { id: authorId },
    data: { banned: true },
  });

  // Invalidate all blog data - nuclear option for bulk change
  cache.admin.blog.revalidateTag();

  // Public needs full refresh of affected areas
  cache.public.blog.posts.revalidateTag();
  cache.public.authors.byId.revalidateTag({ id: authorId });

  // Invalidate all comment caches for this author's posts
  // (In practice, you'd track which posts and invalidate specifically)
}

Pattern 6: Category Reorganization

// app/actions/categories.ts
'use server';

export async function mergeCategories(sourceId: string, targetId: string) {
  // Move all posts to target category
  await db.posts.updateMany({
    where: { categoryId: sourceId },
    data: { categoryId: targetId },
  });

  // Delete source category
  await db.categories.delete({
    where: { id: sourceId },
  });

  // Admin sees immediately
  cache.admin.blog.categories.updateTag();
  cache.admin.blog.posts.updateTag();

  // Public gets SWR
  cache.public.blog.categories.revalidateTag();
  cache.public.blog.posts.byCategory.revalidateTag({ categoryId: sourceId });
  cache.public.blog.posts.byCategory.revalidateTag({ categoryId: targetId });
}

Admin Dashboard

The admin dashboard needs real-time data:

// app/admin/posts/page.tsx
import { cache } from '@/lib/cache';

async function getAdminPostsList() {
  'use cache: remote';
  cache.admin.blog.posts.list.cacheTag();

  return db.posts.findMany({
    orderBy: { updatedAt: 'desc' },
    include: { author: true },
  });
}

async function getDraftsList() {
  'use cache: remote';
  cache.admin.blog.drafts.list.cacheTag();

  return db.posts.findMany({
    where: { status: 'draft' },
    orderBy: { updatedAt: 'desc' },
  });
}

export default async function AdminPostsPage() {
  const [posts, drafts] = await Promise.all([
    getAdminPostsList(),
    getDraftsList(),
  ]);

  return (
    <AdminLayout>
      <DraftsSection drafts={drafts} />
      <AllPostsSection posts={posts} />
    </AdminLayout>
  );
}

Complete Working Example

Here's a complete cache configuration with all the patterns:

// lib/cache.ts
import { createCache } from 'next-cool-cache';

const schema = {
  blog: {
    posts: {
      list: {},
      featured: {},
      byId: { _params: ['id'] as const },
      bySlug: { _params: ['slug'] as const },
      byAuthor: { _params: ['authorId'] as const },
      byCategory: { _params: ['categoryId'] as const },
    },
    drafts: {
      list: {},
      byId: { _params: ['id'] as const },
      byAuthor: { _params: ['authorId'] as const },
    },
    categories: {
      list: {},
      byId: { _params: ['id'] as const },
      bySlug: { _params: ['slug'] as const },
    },
  },
  authors: {
    list: {},
    byId: { _params: ['id'] as const },
  },
  comments: {
    byPost: { _params: ['postId'] as const },
  },
} as const;

const scopes = ['admin', 'public', 'author'] as const;

export const cache = createCache(schema, scopes);

Frequently Asked Questions

How do I edit a blog post and immediately view it?

Use updateTag() to expire the cache immediately. The next request fetches fresh data.

// After updating the post in the database
cache.admin.blog.posts.byId.updateTag({ id: postId });
// → updateTag('admin/blog/posts/byId:post-123')

UX: The editor sees changes instantly on the next page load. No stale content is ever shown - but they may briefly see a loading state while fresh data loads.


How do I edit a blog post and only revalidate it (stale-while-revalidate)?

Use revalidateTag() to serve stale content while fetching fresh data in the background.

// After updating the post in the database
cache.public.blog.posts.byId.revalidateTag({ id: postId });
// → revalidateTag('public/blog/posts/byId:post-123', 'max')

UX: Public readers see the old content immediately (no loading spinner), then the page automatically updates with fresh content in the background. On their next visit, they see the updated post. This is ideal for public-facing pages where avoiding loading states is more important than instant freshness.


How do I preview unpublished drafts without affecting the public cache?

Use separate cache entries for drafts with the author scope, keeping them isolated from public content.

// Caching a draft (only for the author)
async function getDraft(id: string) {
  'use cache: remote';
  cache.author.blog.drafts.byId.cacheTag({ id });
  // → cacheTag('author/blog/drafts/byId:draft-123', 'author/blog/drafts', 'author/blog', 'author', 'blog/drafts/byId:draft-123', 'blog/drafts', 'blog')
  return db.drafts.findById(id);
}

// When the author updates their draft
cache.author.blog.drafts.byId.updateTag({ id: draftId });
// → updateTag('author/blog/drafts/byId:draft-123')

UX: Authors see their own drafts instantly with immediate updates. Public readers never see draft content because it's cached under a different scope. When the draft is published, you invalidate both the draft cache and the public posts cache.


How do I invalidate all posts by a specific author?

Use the byAuthor tag with the author's ID to invalidate all their posts at once.

// When an author is banned or all their content needs refreshing
cache.public.blog.posts.byAuthor.revalidateTag({ authorId: 'author-456' });
// → revalidateTag('public/blog/posts/byAuthor:author-456', 'max')

UX: All posts by that author will be revalidated on the next request. Readers see stale content briefly, then fresh content loads in the background. This is useful for bulk operations like banning an author or when an author updates their display name.


How do I handle cache when changing a post's category?

Invalidate both the old and new category caches so listings update correctly.

async function changePostCategory(postId: string, oldCategoryId: string, newCategoryId: string) {
  await db.posts.update({ where: { id: postId }, data: { categoryId: newCategoryId } });

  // Invalidate the post itself
  cache.public.blog.posts.byId.revalidateTag({ id: postId });
  // → revalidateTag('public/blog/posts/byId:post-123', 'max')

  // Invalidate old category (post should disappear from it)
  cache.public.blog.posts.byCategory.revalidateTag({ categoryId: oldCategoryId });
  // → revalidateTag('public/blog/posts/byCategory:old-cat', 'max')

  // Invalidate new category (post should appear in it)
  cache.public.blog.posts.byCategory.revalidateTag({ categoryId: newCategoryId });
  // → revalidateTag('public/blog/posts/byCategory:new-cat', 'max')
}

UX: The post disappears from the old category listing and appears in the new one. Readers browsing either category will see the correct posts after the background revalidation completes.


Should comments use updateTag or revalidateTag?

Use revalidateTag() for comments - slight staleness is acceptable and avoids loading spinners.

// When a new comment is added
cache.public.comments.byPost.revalidateTag({ postId: 'post-123' });
// → revalidateTag('public/comments/byPost:post-123', 'max')

UX: When someone adds a comment, other readers continue seeing the cached comments instantly (no loading spinner). The new comment appears in the background and shows on subsequent page loads. For the commenter themselves, you might want to use updateTag() on their scope so they see their own comment immediately.

Key Takeaways

  1. Separate concerns by scope: Editors, authors, and readers each have different cache strategies
  2. Be surgical with invalidation: Only invalidate what actually changed
  3. Use SWR for public: Readers should never see loading states for cached content
  4. Use update for editors: Editors expect immediate feedback
  5. Branch invalidation for bulk: When many things change, invalidate at the branch level

On this page