Skip to content

NEXT.JS

Next.js 15 unstable_cache: Database Caching and Tags

With nextjs unstable_cache, you can cache database queries and third-party API payloads in App Router using tags and on-demand revalidation.

In Next.js 15, fetch requests are unmemoized by default, making nextjs unstable_cache the primary mechanism for persisting database queries, compute-heavy transformations, and third-party API responses across requests in the App Router Data Cache. Direct calls to database drivers such as Drizzle, Prisma, or SQLite bypass HTTP layers entirely and require explicit caching wrappers to avoid redundant execution.

By combining unstable_cache from next/cache with granular cache keys, semantic tags, and on-demand revalidation, you can achieve sub-millisecond data delivery while keeping database load near zero. Here is how to configure cached query functions, isolate tenant state, and invalidate cached data on mutation.

Key takeaways

  • Next.js 15 sets fetch caching to no-store by default, requiring explicit caching for shared data layers.
  • Direct database drivers do not use the HTTP cache; unstable_cache bridges raw database queries into the framework Data Cache.
  • The keyParts array establishes a deterministic namespace, while function arguments serialize into unique per-item cache entries.
  • Next.js cache tags decouple cache lifetimes from fixed TTL clocks, enabling instant updates via revalidateTag.
  • Request-specific headers and cookies must be resolved outside unstable_cache and injected as plain scalar arguments.

Next.js 15 disables fetch caching by default

The transition from Next.js 14 to Next.js 15 altered the framework's caching defaults. In Next.js 14, standard fetch requests inside Server Components were cached aggressively using force-cache unless developers explicitly opted out with no-store or dynamic directives. Next.js 15 flipped this default: all fetch requests now default to cache: 'no-store'.

While this change eliminated stale-data surprises for beginners, it transferred the caching responsibility squarely onto the developer. More importantly, real-world production architectures rarely fetch core entity data through intermediate HTTP endpoints. Server Components typically query databases directly through native TCP pools, Unix domain sockets, or serverless HTTP APIs like Cloudflare D1.

Direct database clients have no concept of HTTP headers or fetch middleware. Calling db.select().from(articles) inside a Server Component executes on every page render unless wrapped in an explicit cache container. The Next.js unstable_cache documentation defines unstable_cache specifically to bridge arbitrary async JavaScript execution into the persistent Next.js Data Cache.

Next.js VersionDefault fetch PolicyDatabase Query CachingRevalidation Strategy
Next.js 14force-cacheUncached unless wrappedTime-based or revalidateTag
Next.js 15no-store (uncached)Uncached unless wrappedOn-demand via revalidateTag

unstable_cache signature and key parts

The unstable_cache helper takes three parameters: an asynchronous query callback, an array of static key identifiers, and an optional configuration object declaring TTL and semantic tags.

lib/cache/article-cache.ts
import { unstable_cache } from "next/cache";
import { db } from "@/db";
import { articles } from "@/db/schema";
import { eq } from "drizzle-orm";

export const getCachedArticleBySlug = unstable_cache(
  async (slug: string) => {
    const records = await db
      .select()
      .from(articles)
      .where(eq(articles.slug, slug))
      .limit(1);

    return records[0] ?? null;
  },
  ["articles", "by-slug"], // keyParts
  {
    revalidate: 3600, // TTL in seconds (1 hour)
    tags: ["articles"], // cache tags
  }
);

Understanding how Next.js computes the internal cache key is critical for preventing collisions. Next.js constructs the Data Cache lookup key by concatenating:

  • 1. Function hash: The internal representation and identifier of the wrapped async callback.
  • 2. Key namespace: The array of string elements passed in the keyParts argument (["articles", "by-slug"]).
  • 3. Serialized parameters: The runtime arguments passed into the invocation ("nextjs-unstable-cache").

If two cached functions share the same keyParts and receive identical arguments, they resolve to the same underlying cache entry. Always include an unambiguous domain prefix in keyParts to ensure cache partition hygiene across different entities.

Database query caching with Drizzle and SQL

When applying nextjs database caching across a real application, cached helper functions should live in dedicated data-access modules rather than inline within component files. This architecture allows multiple Server Components, Route Handlers, and Metadata resolvers to share the exact same cached payload without duplicating query overhead.

Consider a multi-category article index that reads from SQLite or Postgres:

lib/data/categories.ts
import { unstable_cache } from "next/cache";
import { d1Query } from "@/lib/d1";

export interface ArticleSummary {
  id: string;
  slug: string;
  title: string;
  publishedIso: string;
}

export const getArticlesByCategory = unstable_cache(
  async (categorySlug: string, limit = 10): Promise<ArticleSummary[]> => {
    const sql = `
      SELECT id, slug, title, published_iso AS publishedIso
      FROM articles
      WHERE category = ?
      ORDER BY published_iso DESC
      LIMIT ?
    `;

    const result = await d1Query<ArticleSummary>(sql, [categorySlug, limit]);
    return result.results;
  },
  ["category-feed"],
  {
    revalidate: 86400, // 24-hour fallback TTL
    tags: ["articles", "feeds"],
  }
);

On the initial server hit, Next.js calls d1Query, stores the resolved array in the App Router Data Cache, and returns the data. On subsequent requests from any client worldwide, getArticlesByCategory("next-js", 10) resolves from disk or memory in less than 1 millisecond without opening a network connection to the database.

Tag-based revalidation with revalidateTag

Fixed time-to-live intervals (revalidate: 3600) work well for stable reference data, but active publishing platforms require instantaneous cache eviction when content updates. The revalidatetag nextjs 15 workflow decouples data freshness from arbitrary clock ticks.

By assigning semantic tags during cache definition, you can purge single articles or whole collections during mutations:

lib/data/articles.ts
import { unstable_cache } from "next/cache";
import { db } from "@/db";

export function getCachedArticle(id: string) {
  return unstable_cache(
    async () => {
      return db.query.articles.findFirst({
        where: (fields, { eq }) => eq(fields.id, id),
      });
    },
    ["article-entity", id],
    {
      revalidate: false, // Do not expire on a timer; rely on tags
      tags: ["articles", `article:${id}`],
    }
  )();
}

When an editor updates an article through a Server Action or admin webhook, invoke revalidateTag per the Next.js revalidateTag documentation:

app/admin/actions.ts
"use server";

import { revalidateTag } from "next/cache";
import { db } from "@/db";
import { articles } from "@/db/schema";
import { eq } from "drizzle-orm";

export async function updateArticleAction(id: string, newTitle: string) {
  await db
    .update(articles)
    .set({ title: newTitle, updatedAt: new Date() })
    .where(eq(articles.id, id));

  // Invalidate this specific article and broad feeds
  revalidateTag(`article:${id}`);
  revalidateTag("feeds");

  return { success: true };
}

Next.js marks all cache entries tagged with article:${id} and feeds as expired immediately. The next visitor triggers a background cache refresh without requiring an entire site redeployment.

Cache isolation across arguments and tenants

A common pitfall with app router data cache functions is accidental cross-tenant data leaks. Because unstable_cache persists data globally across requests in server memory and build caches, queries that depend on user identity or organizational boundaries must parameterize their keys explicitly.

Never attempt to read request headers or authentication sessions inside the cached callback:

lib/data/tenant-documents.ts
import { unstable_cache } from "next/cache";
import { db } from "@/db";

// INCORRECT: Reading cookies or headers inside unstable_cache throws
// const badCached = unstable_cache(async () => {
//   const session = await auth(); // DYNAMIC SERVER ERROR
//   return db.docs.findMany({ tenantId: session.tenantId });
// }, ["tenant-docs"]);

// CORRECT: Pass the tenant ID as an explicit argument
export const getCachedTenantDocs = unstable_cache(
  async (tenantId: string) => {
    return db.query.documents.findMany({
      where: (doc, { eq }) => eq(doc.tenantId, tenantId),
    });
  },
  ["tenant-documents"],
  {
    revalidate: 7200,
    tags: ["documents"],
  }
);

In your Server Component, authenticate the request first, extract the verified tenantId, and pass it to the cached helper:

app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { getCachedTenantDocs } from "@/lib/data/tenant-documents";
import { redirect } from "next/navigation";

export default async function DashboardPage() {
  const session = await auth();
  if (!session) redirect("/login");

  // tenantId is serialized into the cache key automatically
  const documents = await getCachedTenantDocs(session.tenantId);

  return (
    <div>
      <h1>Workspace Documents ({documents.length})</h1>
      {/* Render documents */}
    </div>
  );
}

Because tenantId forms the trailing segment of the internal cache key, Tenant A and Tenant B never share cache records, even under high concurrent load.

Best practices for Next.js data caching

  • Namespace keyParts cleanly: Always include both the resource type and access pattern in keyParts, such as ["catalog", "by-sku"] or ["authors", "summary"].
  • Prefer tag revalidation over short TTLs: Use revalidate: false or long TTLs (days or weeks) combined with revalidateTag in mutation handlers instead of polling databases with 30-second clocks.
  • Keep returned payloads serializable: Ensure cached functions return plain JSON objects, arrays, numbers, or strings. Do not return class instances, functions, or complex symbols that fail standard serialization.
  • Pre-sanitize arguments: Always pass primitives (strings, numbers) or sorted plain objects. If you pass an object with varying key orders, argument serialization may generate duplicate cache misses for identical logical parameters.
  • Audit memory ceilings: In serverless or containerized environments, monitor Data Cache disk and memory footprints. Large binary payloads belong in object storage like Cloudflare R2, not in unstable_cache.

Common unstable_cache mistakes

  • Mistake 1: Invoking headers() or cookies() inside the cached function. Symptom: Build crashes with DynamicServerError: Route couldn't be rendered statically because it used headers(). Fix: Read headers, cookies, or search parameters in the page Server Component and pass values into the cached function as plain parameters.

  • Mistake 2: Missing keyParts array. Symptom: Distinct functions with similar argument shapes overwrite each other's cached values. Fix: Supply a unique string array as the second argument to unstable_cache for every distinct query.

  • Mistake 3: Relying on fetch cache in Next.js 15 without unstable_cache for databases. Symptom: Database queries hit the serverless database on every hit, causing connection pool exhaustion and elevated hosting costs. Fix: Wrap database queries in unstable_cache with relevant tags.

  • Mistake 4: Calling revalidatePath when revalidateTag is sufficient. Symptom: Broad page revalidations wipe entire layout render trees rather than just refreshing the affected query record. Fix: Target data updates granularly using revalidateTag(entityTag).

Conclusion: Deterministic query caching

Modern full-stack React applications require predictable performance without sacrificing content freshness. The shift in Next.js 15 away from automatic fetch caching makes explicit caching boundaries essential for high-performance deployments.

By wrapping database drivers and expensive computational steps with unstable_cache, structuring deterministic keyParts, and firing targeted revalidateTag calls upon data modification, you establish an ironclad data caching architecture that protects downstream databases while serving users at edge speeds.

Frequently asked questions

Why is unstable_cache needed if Next.js already has data caching?
In Next.js 14, fetch requests were cached by default, but direct database calls via Drizzle, Prisma, or SQLite bypassed the Data Cache completely. Next.js 15 further inverted fetch caching to no-store by default. unstable_cache provides the framework-level mechanism to store raw database query results and arbitrary computation in the Next.js Data Cache.
What is the difference between keyParts and arguments in unstable_cache?
keyParts are static string identifiers passed as an array to name the cache scope, such as ['posts', 'by-slug']. Any runtime arguments passed to the wrapped function are automatically serialized and appended to keyParts to form the final cache key. Both must be serializable and stable to avoid cache collisions or accidental misses.
How does revalidateTag work with unstable_cache in Next.js 15?
When defining an unstable_cache wrapper, you supply an array of semantic tags in the options object. Calling revalidateTag(tag) in a Server Action or Route Handler marks all cached entries associated with that tag as stale. Subsequent requests trigger a cache miss, re-running the underlying query and updating the Data Cache.
Can unstable_cache read cookies or headers from next/headers?
No. unstable_cache runs outside the request context in a standalone cache worker. Invoking cookies() or headers() inside an unstable_cache callback throws a dynamic server usage error. Any request-dependent data like a tenant ID or user session must be read in the Server Component and passed into the cached function as an explicit argument.

Muhammad Kashif

Founder and editor of Devventa, covering AI coding assistants, Next.js and the modern AI development stack.