Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/cloudflare/vinext/llms.txt

Use this file to discover all available pages before exploring further.

vinext implements Incremental Static Regeneration (ISR) and caching using a pluggable architecture. The default in-memory cache works out of the box, and you can swap in production backends like Cloudflare KV, Redis, or DynamoDB.

Cache Architecture

vinext’s caching system has two layers:
1

CacheHandler (Storage Layer)

A pluggable key-value store matching Next.js 16’s CacheHandler interface. Handles get/set operations with metadata.
2

ISR Layer (Semantics)

Sits above the CacheHandler and implements stale-while-revalidate, background regeneration, and tag-based invalidation.

CacheHandler Interface

The CacheHandler is a simple key-value store:
packages/vinext/src/shims/cache.ts
export abstract class CacheHandler {
  abstract get(key: string): Promise<CacheHandlerValue | null>;
  
  abstract set(
    key: string,
    data: IncrementalCacheValue,
    options: { revalidate?: number; tags?: string[] }
  ): Promise<void>;
  
  abstract revalidateTag(tag: string): Promise<void>;
}

export interface CacheHandlerValue {
  value: IncrementalCacheValue;
  cacheState?: 'fresh' | 'stale';
  lastModified?: number;
}

export type IncrementalCacheValue =
  | CachedPagesValue    // Pages Router HTML + pageData
  | CachedAppPageValue  // App Router HTML + RSC stream
  | CachedRouteValue;   // Route handler response

ISR Layer Implementation

The ISR layer wraps the CacheHandler:
packages/vinext/src/server/isr-cache.ts
export async function isrGet(key: string): Promise<ISRCacheEntry | null> {
  const handler = getCacheHandler();
  const result = await handler.get(key);
  if (!result || !result.value) return null;

  return {
    value: result,
    isStale: result.cacheState === "stale",
  };
}

export async function isrSet(
  key: string,
  data: IncrementalCacheValue,
  revalidateSeconds: number,
  tags?: string[],
): Promise<void> {
  const handler = getCacheHandler();
  await handler.set(key, data, {
    revalidate: revalidateSeconds,
    tags: tags ?? [],
  });
}

Stale-While-Revalidate

ISR returns stale content immediately while regenerating in the background:
const cached = await isrGet(cacheKey);

if (cached?.isStale) {
  // Serve stale content immediately
  respondWith(cached.value);
  
  // Trigger background regeneration
  triggerBackgroundRegeneration(cacheKey, async () => {
    const fresh = await renderPage();
    await isrSet(cacheKey, fresh, revalidateSeconds);
  });
} else if (cached) {
  // Fresh hit — serve immediately
  respondWith(cached.value);
} else {
  // Cache miss — render, cache, serve
  const rendered = await renderPage();
  await isrSet(cacheKey, rendered, revalidateSeconds);
  respondWith(rendered);
}

Deduplication

Multiple concurrent requests for the same stale key only trigger one regeneration:
packages/vinext/src/server/isr-cache.ts
const pendingRegenerations = new Map<string, Promise<void>>();

export function triggerBackgroundRegeneration(
  key: string,
  renderFn: () => Promise<void>,
): void {
  if (pendingRegenerations.has(key)) return; // Already regenerating

  const promise = renderFn()
    .catch((err) => {
      console.error(`[vinext] ISR regeneration failed for ${key}:`, err);
    })
    .finally(() => {
      pendingRegenerations.delete(key);
    });

  pendingRegenerations.set(key, promise);
}
This prevents “thundering herd” — a spike in traffic to a stale page only renders it once.

Pages Router ISR

Enable ISR by returning revalidate from getStaticProps:
pages/blog/[slug].tsx
export async function getStaticProps({ params }) {
  const post = await db.getPost(params.slug);
  
  return {
    props: { post },
    revalidate: 60, // Revalidate every 60 seconds
  };
}

export async function getStaticPaths() {
  const posts = await db.getAllPosts();
  
  return {
    paths: posts.map(p => ({ params: { slug: p.slug } })),
    fallback: 'blocking', // Render on-demand for missing paths
  };
}

export default function Post({ post }) {
  return <article>{post.content}</article>;
}

Fallback Modes

ModeBehavior
falseOnly pre-rendered paths exist. 404 for others.
trueServe fallback UI while rendering new paths.
'blocking'Server-render new paths (no fallback UI).
export async function getStaticPaths() {
  return {
    paths: [{ params: { id: '1' } }],
    fallback: 'blocking',
  };
}
With fallback: 'blocking':
  1. Request /posts/2 (not pre-rendered)
  2. vinext renders the page on-demand
  3. Caches the result with revalidate TTL
  4. Future requests serve from cache with stale-while-revalidate

App Router ISR

Enable ISR with route segment config:
app/blog/[slug]/page.tsx
// Revalidate every 60 seconds
export const revalidate = 60;

export default async function Post({ params }) {
  const { slug } = await params;
  const post = await db.getPost(slug);
  
  return <article>{post.content}</article>;
}

Dynamic Rendering

Control when pages render:
app/dashboard/page.tsx
// Force static (at build time)
export const dynamic = 'force-static';

// Force dynamic (on every request)
export const dynamic = 'force-dynamic';

// Auto (static if possible, dynamic if needed)
export const dynamic = 'auto'; // default

Tag-Based Revalidation

Invalidate cache entries by tag:
app/posts/[id]/page.tsx
import { unstable_cache } from 'next/cache';

const getCachedPost = unstable_cache(
  async (id) => db.getPost(id),
  ['post'],
  { tags: [`post-${id}`] }
);

export default async function Post({ params }) {
  const { id } = await params;
  const post = await getCachedPost(id);
  
  return <article>{post.content}</article>;
}
Invalidate when data changes:
app/actions.ts
"use server";

import { revalidateTag } from 'next/cache';

export async function updatePost(id: string, data: any) {
  await db.updatePost(id, data);
  
  // Invalidate cache for this post
  revalidateTag(`post-${id}`);
}

Path-Based Revalidation

import { revalidatePath } from 'next/cache';

export async function createPost(data: any) {
  await db.createPost(data);
  
  // Invalidate all blog pages
  revalidatePath('/blog', 'page'); // Just /blog
  revalidatePath('/blog', 'layout'); // /blog and /blog/*
}

"use cache" Directive

Next.js 16 introduced "use cache" for granular caching:

Function-Level Caching

app/components/RecentPosts.tsx
import { cache } from 'react';
import { cacheLife } from 'next/cache';

const getRecentPosts = cache(async () => {
  "use cache";
  cacheLife('hours');
  
  return await db.query('SELECT * FROM posts ORDER BY date DESC LIMIT 10');
});

export default async function RecentPosts() {
  const posts = await getRecentPosts();
  
  return (
    <ul>
      {posts.map(p => <li key={p.id}>{p.title}</li>)}
    </ul>
  );
}

File-Level Caching

app/lib/posts.ts
"use cache";

import { cacheLife, cacheTag } from 'next/cache';

export async function getPost(id: string) {
  cacheLife('days');
  cacheTag(`post-${id}`);
  
  return await db.getPost(id);
}

Cache Profiles

Define reusable cache durations:
next.config.js
module.exports = {
  experimental: {
    cacheLife: {
      hours: { stale: 3600, revalidate: 7200 },
      days: { stale: 86400, revalidate: 604800 },
    },
  },
};

Cloudflare KV Cache Handler

For production on Cloudflare Workers, use the KV cache handler:
worker/index.ts
import { KVCacheHandler } from 'vinext/cloudflare';
import { setCacheHandler } from 'next/cache';

export default {
  async fetch(request, env) {
    // Set KV as cache backend
    setCacheHandler(new KVCacheHandler(env.CACHE_KV));
    
    // Handle request
    return handleRequest(request);
  },
};

Binding KV Namespace

Add to wrangler.jsonc:
wrangler.jsonc
{
  "name": "my-app",
  "main": "worker/index.ts",
  "compatibility_date": "2024-01-01",
  "kv_namespaces": [
    {
      "binding": "CACHE_KV",
      "id": "your-kv-namespace-id"
    }
  ]
}
Create the namespace:
wrangler kv:namespace create CACHE_KV

KV Implementation

packages/vinext/src/cloudflare/kv-cache-handler.ts
export class KVCacheHandler extends CacheHandler {
  constructor(private kv: KVNamespace) {
    super();
  }

  async get(key: string): Promise<CacheHandlerValue | null> {
    const data = await this.kv.get(key, 'json');
    if (!data) return null;
    
    const { value, expiresAt, tags } = data;
    const now = Date.now();
    
    return {
      value,
      cacheState: now > expiresAt ? 'stale' : 'fresh',
      lastModified: data.lastModified,
    };
  }

  async set(
    key: string,
    value: IncrementalCacheValue,
    { revalidate, tags }: { revalidate?: number; tags?: string[] }
  ): Promise<void> {
    const now = Date.now();
    const expiresAt = revalidate ? now + revalidate * 1000 : null;
    
    await this.kv.put(key, JSON.stringify({
      value,
      expiresAt,
      tags: tags ?? [],
      lastModified: now,
    }));
    
    // Store tag → keys mapping for revalidateTag
    for (const tag of tags ?? []) {
      await this.addTagKey(tag, key);
    }
  }

  async revalidateTag(tag: string): Promise<void> {
    const keys = await this.getTagKeys(tag);
    await Promise.all(keys.map(k => this.kv.delete(k)));
  }
}

Custom Cache Handlers

Implement the CacheHandler interface for other backends:
lib/redis-cache.ts
import { CacheHandler } from 'next/cache';
import Redis from 'ioredis';

export class RedisCacheHandler extends CacheHandler {
  private redis: Redis;
  
  constructor(redisUrl: string) {
    super();
    this.redis = new Redis(redisUrl);
  }
  
  async get(key: string) {
    const data = await this.redis.get(key);
    if (!data) return null;
    
    const { value, expiresAt } = JSON.parse(data);
    const now = Date.now();
    
    return {
      value,
      cacheState: now > expiresAt ? 'stale' : 'fresh',
    };
  }
  
  async set(key: string, value: any, { revalidate, tags }) {
    const now = Date.now();
    const expiresAt = revalidate ? now + revalidate * 1000 : null;
    
    await this.redis.set(key, JSON.stringify({ value, expiresAt, tags }));
    
    // Set Redis TTL (stale entries are kept for stale-while-revalidate)
    if (revalidate) {
      await this.redis.expire(key, revalidate * 2);
    }
  }
  
  async revalidateTag(tag: string) {
    // Scan for keys with this tag and delete
    const keys = await this.redis.keys(`*`);
    for (const key of keys) {
      const data = await this.redis.get(key);
      if (data) {
        const { tags } = JSON.parse(data);
        if (tags?.includes(tag)) {
          await this.redis.del(key);
        }
      }
    }
  }
}
Register it:
import { setCacheHandler } from 'next/cache';
import { RedisCacheHandler } from './lib/redis-cache';

setCacheHandler(new RedisCacheHandler(process.env.REDIS_URL));

Cache Key Generation

vinext generates cache keys from the router type and pathname:
packages/vinext/src/server/isr-cache.ts
export function isrCacheKey(router: 'pages' | 'app', pathname: string): string {
  const normalized = pathname === "/" ? "/" : pathname.replace(/\/$/, "");
  const key = `${router}:${normalized}`;
  
  // Hash long pathnames to stay within KV key-length limits (512 bytes)
  if (key.length <= 200) return key;
  return `${router}:__hash:${fnv1a64(normalized)}`;
}
Examples:
  • pages:/ → root page
  • pages:/blog/hello-world → blog post
  • app:/dashboard/analytics → app route
  • app:__hash:a3f2b91c → long pathname (hashed)

Next Steps

Deployment

Deploy to Cloudflare Workers with KV cache

Server Components

Learn about RSC rendering

Server Actions

Mutate data and revalidate cache

Architecture

Understand vinext’s architecture

Build docs developers (and LLMs) love