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
- Separate concerns by scope: Editors, authors, and readers each have different cache strategies
- Be surgical with invalidation: Only invalidate what actually changed
- Use SWR for public: Readers should never see loading states for cached content
- Use update for editors: Editors expect immediate feedback
- Branch invalidation for bulk: When many things change, invalidate at the branch level