The pattern: wrap each domain operation in a cached function. Pages and components call those functions; the cache layer is invisible.
1// lib/data/courses.ts
2"use cache";
3import { cacheTag, cacheLife } from "next/cache";
4import { db } from "@/lib/db";
5import "server-only";
6
7export async function getCourse(slug: string) {
8 cacheTag("course", `course:${slug}`);
9 cacheLife("hours");
10 return db.course.findUnique({
11 where: { slug },
12 include: { sections: { include: { lessons: true } } },
13 });
14}
15
16export async function getFeaturedCourses() {
17 cacheTag("course");
18 cacheLife("hours");
19 return db.course.findMany({
20 where: { status: "PUBLISHED" },
21 orderBy: { createdAt: "desc" },
22 take: 6,
23 });
24}
25
26export async function getReviews(courseId: string) {
27 cacheTag(`reviews:${courseId}`);
28 cacheLife("minutes");
29 return db.review.findMany({
30 where: { courseId },
31 include: { user: { select: { name: true, avatar: true } } },
32 orderBy: { createdAt: "desc" },
33 });
34}1// lib/data/mutations.ts
2"use server";
3import { revalidateTag } from "next/cache";
4import { db } from "@/lib/db";
5
6export async function publishCourse(id: string) {
7 const course = await db.course.update({ where: { id }, data: { status: "PUBLISHED" } });
8 revalidateTag("course");
9 revalidateTag(`course:${course.slug}`);
10 return course;
11}
12
13export async function postReview(courseId: string, input: { rating: number; comment: string }) {
14 const review = await db.review.create({ data: { courseId, ...input } });
15 revalidateTag(`reviews:${courseId}`);
16 return review;
17}1// app/courses/[slug]/page.tsx
2import { getCourse, getReviews } from "@/lib/data/courses";
3
4export default async function CoursePage({ params }: { params: Promise<{ slug: string }> }) {
5 const { slug } = await params;
6 const [course, reviews] = await Promise.all([
7 getCourse(slug),
8 getReviews(slug),
9 ]);
10
11 if (!course) notFound();
12 return <CourseLayout course={course} reviews={reviews} />;
13}The page is plain code — no cache wiring visible. All caching lives in the data layer.
A cached function can call another cached function. Tags merge:
1"use cache";
2export async function getCourseWithStats(slug: string) {
3 cacheTag(`course:${slug}`);
4 cacheLife("hours");
5
6 const course = await getCourse(slug); // also cached
7 const reviews = await getReviews(course!.id); // also cached
8 const stats = computeStats(reviews);
9 return { ...course, stats };
10}Invalidating course:${slug} wipes both this composed function and its sub-calls.
| Case | Reason |
|---|---|
| Authenticated user data | Different per user; cache pollution risk |
| Real-time data (prices, scores) | Expected to be fresh |
| Personalized recommendations | Same as above |
| Cart contents | Per-session, ephemeral |
| Anything with PII in args | Tag explosion + privacy concerns |
For these, just call the DB directly — no "use cache".
A small, deliberate tag scheme makes invalidation trivial:
course ← the whole "courses" namespace
course:<slug> ← one specific course
reviews:<courseId> ← reviews for one course
user:<id>:enrollments ← a user's enrollment list
When in doubt, tag more, not less — but avoid PII (emails, IPs) in tag names; they're stored verbatim.