diff --git a/apps/docs/content/docs/dev/advanced/redis.mdx b/apps/docs/content/docs/dev/advanced/redis.mdx
index 181bdd053..3d1c50e9a 100644
--- a/apps/docs/content/docs/dev/advanced/redis.mdx
+++ b/apps/docs/content/docs/dev/advanced/redis.mdx
@@ -55,6 +55,12 @@ REDIS_URL=redis://localhost:6379
REDIS_PASSWORD=root
```
+
+ The API takes its connection from `vitnode.api.config.ts`, but the [Next.js cache
+ handlers](#caching-nextjs-output) read these variables directly. If you run the
+ web app and the API as separate services, set them on both.
+
+
`root` is a convenience default for local development. Set a strong
`REDIS_PASSWORD` in production.
@@ -189,6 +195,12 @@ export const postsRoute = buildRoute({
## Caching database queries
+
+ This page covers the cache *inside* the API. The Next.js cache in front of it
+ stores whole responses and is expired differently. [Cache](/docs/dev/cache)
+ covers both and how to choose between them.
+
+
The most useful method is `remember` - it runs your loader only on a cache
miss, which is perfect for hot, rarely-changing data. Good candidates are
reference data such as role-name translations or resolved staff permissions.
@@ -244,6 +256,109 @@ revoking an admin takes effect immediately - not when the cache expires.
it never changes what a request can see beyond the short TTL above.
+## Staff permission caching
+
+Resolving what an admin or a moderator is allowed to do takes three queries: the
+user's roles, whether any of them is `root`, and the staff entries attached to
+that user or those roles. Every gated request runs it, and an AdminCP page that
+renders a dozen gated elements runs it a dozen times.
+
+**When `redis` is configured, VitNode caches the resolved set for you** - keyed
+per user and per kind, in the shared framework namespace, for at most 60 seconds.
+
+The TTL is a backstop rather than the mechanism: the routes that can change the
+answer expire the cache themselves, so a permission change is live immediately.
+
+- Editing a **staff entry attached to a user** clears that user's entry.
+- Editing a **role**, deleting one, or editing a **staff entry attached to a
+ role** moves a generation stamp that every key is built from, which expires
+ every user's cached set in a single write - the members of a role are not a set
+ a mutation can cheaply enumerate.
+- Changing a user's **primary or secondary roles** clears that user's entry.
+
+
+ Without Redis the read misses, the write is a no-op, and every request resolves
+ permissions from the database exactly as before.
+
+
+## Caching Next.js output
+
+Everything above is the cache *inside* the API. Redis also backs the **Next.js**
+caches in the web process, so a cached page or `fetch` response is shared by
+every instance instead of being rebuilt once per container.
+
+**There is nothing to add to `next.config.ts`.** `vitNodeNextConfig` wires the
+handlers up when `REDIS_URL` is set - the same variable that turns Redis on for
+the API - because Next takes a cache handler as a module *path* and cannot be
+handed a config object:
+
+```ts title="src/next.config.ts"
+// Already all it takes - the handlers follow REDIS_URL.
+export default vitNodeNextConfig(nextConfig);
+```
+
+
+ The API gets its connection from `vitnode.api.config.ts`; the cache handlers
+ cannot - they are loaded by Next itself, outside your app's module graph. They
+ read `REDIS_URL` and `REDIS_PASSWORD` from the environment, so both must be set
+ wherever the **web** process runs, not only where the API runs. In a split
+ deployment that means adding them to the frontend service too.
+
+
+### What each handler covers
+
+VitNode registers two, because Next has two caches and they are not the same one:
+
+
+
+Prerendered pages are deliberately left on disk. A custom handler *replaces*
+Next's filesystem cache outright, and the pages `next build` produced only exist
+there - putting them in Redis would mean serving one deploy's HTML from another
+deploy's cache. Their payloads are also Next-internal structures that would need
+re-checking on every upgrade. The build output is identical on every instance
+anyway, so there is nothing to gain by sharing it.
+
+### Why it matters: shared invalidation
+
+The part that is genuinely broken without this is not speed, it is correctness.
+`updateTag` runs on whichever instance handled the Server Action. With per-process
+caches the other instances never hear about it and keep serving the old page until
+their own copies age out.
+
+Both handlers record every revalidation in Redis and check it on read, so an
+invalidation raised anywhere is seen everywhere - including for the prerendered
+pages that still live on local disk, which each instance then rebuilds for itself.
+
+### Behaviour and limits
+
+- **A cold start misses.** The connection is opened in the background and commands
+ fail fast until it is ready, so the first requests after a boot render normally.
+- **A Redis outage is a miss, never an error.** Reads fall through and writes are
+ dropped; pages render as if nothing were cached.
+- **Entries carry a TTL** so abandoned keys cannot accumulate: `use cache` entries
+ expire at their `cacheLife` `expire`, stored `fetch` responses after 7 days, and
+ revalidation markers after 30 days.
+- **Everything lives under `vitnode:next:`**, separate from the API's
+ `vitnode:cache:` namespace, so neither side can clear the other by accident.
+
+
+ To use Redis for the API but keep Next on its built-in caches, override the two
+ keys after the wrapper: `vitNodeNextConfig({ cacheHandler: undefined, cacheHandlers: undefined })`.
+
+
## Rate limiting with Redis
When `redis` is configured, the [rate limiter](/docs/dev/advanced/rate-limiter)
diff --git a/apps/docs/content/docs/dev/cache.mdx b/apps/docs/content/docs/dev/cache.mdx
new file mode 100644
index 000000000..5cd23e39b
--- /dev/null
+++ b/apps/docs/content/docs/dev/cache.mdx
@@ -0,0 +1,183 @@
+---
+title: Cache
+description: Learn how caching works in VitNode across Next.js and Redis.
+---
+
+VitNode uses a simple two-layer caching model:
+
+1. **Next.js Cache (App)**: Caches rendered UI and public API responses.
+2. **Redis Cache (API)**: Caches database queries and heavy computations inside Hono route handlers.
+
+Caching is **opt-in**. Dynamic data stays fresh by default.
+
+---
+
+## App Caching (Next.js)
+
+### Public Data (`"use cache"`)
+
+For public data shared by all visitors (e.g. blog posts, site configuration), use Next.js `"use cache"`.
+
+Always use `coreFetcher` (which skips request cookies) and call `await connection()` so Next.js can prerender safely:
+
+```ts title="services/announcements.ts"
+import { coreFetcher } from '@vitnode/core/lib/fetcher/core';
+import { cacheLife, cacheTag } from 'next/cache';
+import { connection } from 'next/server';
+
+export const getAnnouncements = async (locale: string) => {
+ 'use cache';
+ cacheLife('hours');
+ cacheTag('announcements');
+ await connection();
+
+ const res = await coreFetcher(announcementsModule, {
+ module: 'announcements',
+ path: '/',
+ method: 'get',
+ args: { query: { lang: locale } },
+ });
+
+ return await res.json();
+};
+```
+
+Render cached data inside a `` boundary in your page:
+
+```tsx title="page.tsx"
+}>
+
+
+```
+
+
+ Common presets: `'seconds'`, `'minutes'`, `'hours'`, `'days'`, `'weeks'`, `'max'`, or custom `{ stale, revalidate, expire }`.
+
+
+### Per-User Data (`React.cache`)
+
+Never cache user-specific data (such as sessions or permissions) with `"use cache"`.
+
+Instead, use React's `cache()` to deduplicate calls within a single render pass without sharing data between visitors:
+
+```ts title="services/profile.ts"
+import { fetcher } from '@vitnode/core/lib/fetcher';
+import { cache } from 'react';
+
+export const getMyProfile = cache(async () => {
+ const res = await fetcher(usersModule, {
+ module: 'users',
+ path: '/me',
+ method: 'get',
+ });
+
+ return await res.json();
+});
+```
+
+---
+
+## Invalidation APIs
+
+When data changes, expire the cached entries using the appropriate helper:
+
+import { TypeTable } from "fumadocs-ui/components/type-table";
+
+
+
+### Invalidation Example
+
+Call `updateTag` inside a Server Action after mutating data:
+
+```ts title="mutation-api.server.ts"
+'use server';
+
+import { fetcher } from '@vitnode/core/lib/fetcher';
+import { updateTag } from 'next/cache';
+
+export const publishAnnouncement = async (id: number) => {
+ const res = await fetcher(announcementsModule, {
+ module: 'announcements',
+ path: '/{id}/publish',
+ method: 'post',
+ args: { params: { id } },
+ });
+
+ // Purge the cache immediately
+ updateTag('announcements');
+
+ return await res.json();
+};
+```
+
+
+ Content Engine entries are automatically tagged and revalidated for you via `revalidateContent`. See [Content Engine Caching](/docs/dev/content-engine/public-api-and-caching).
+
+
+---
+
+## Where the App cache is stored
+
+By default Next.js keeps `"use cache"` entries in a per-process memory cache and everything else on local disk. That is fine for one instance and wrong for several: an `updateTag` runs on whichever instance handled the Server Action, and the others keep serving what they already had.
+
+**Set `REDIS_URL` and VitNode swaps both Next.js cache handlers for Redis-backed ones.** No `next.config.ts` change is required - `vitNodeNextConfig` registers them:
+
+```ts title="src/next.config.ts"
+// Unchanged. The handlers follow REDIS_URL.
+export default vitNodeNextConfig(nextConfig);
+```
+
+What that buys you:
+
+- `"use cache"` entries are stored in Redis, so they survive restarts and rolling deploys and are shared between instances.
+- `fetch` Data Cache responses are shared too.
+- **Every tag revalidation is shared**, so `updateTag` on one instance invalidates all of them - including for prerendered pages, which stay on each instance's own disk and are rebuilt individually.
+
+
+ The handlers are loaded by Next.js itself and read `REDIS_URL` / `REDIS_PASSWORD` straight from the environment - they cannot see `vitnode.api.config.ts`. In a split deployment, set them on the **frontend** service as well as the API.
+
+
+See [Redis](/docs/dev/advanced/redis#caching-nextjs-output) for what each handler covers, TTLs, and how to opt out.
+
+## API Caching (Redis)
+
+Inside your Hono API route handlers, use `c.get("cache")` to store expensive query results in Redis:
+
+```ts title="plugins/stats/src/routes/stats.ts"
+// Cache result on miss for 5 minutes
+const stats = await c.get('cache').remember(
+ `stats:${containerId}`,
+ 60 * 5, // TTL in seconds
+ async () => await calculateHeavyStats(c, containerId),
+);
+```
+
+### Invalidation
+
+Delete the key whenever the underlying record changes:
+
+```ts title="plugins/stats/src/routes/update.ts"
+await c.get('cache').delete(`stats:${containerId}`);
+```
+
+
+ Redis is optional. If not configured, `c.get("cache")` gracefully acts as a no-op and runs the callback directly. See [Redis Setup](/docs/dev/advanced/redis).
+
diff --git a/apps/docs/content/docs/dev/fetcher.mdx b/apps/docs/content/docs/dev/fetcher.mdx
index 17f733da2..8dbe9465e 100644
--- a/apps/docs/content/docs/dev/fetcher.mdx
+++ b/apps/docs/content/docs/dev/fetcher.mdx
@@ -63,20 +63,42 @@ const response = await fetcher(categoriesAdminModule, {
### Caching Responses
-You can leverage Next.js caching by passing cache options:
+`fetcher()` forwards the incoming request's cookies and headers, which means two
+things at once: the API knows who is asking, and the call can never sit inside a
+`"use cache"` function. For a response that is the same for every visitor, reach
+for `coreFetcher` - the same typed interface without the request-bound headers -
+and cache that:
```ts
-const response = await fetcher(usersModule, {
- path: '/session',
- method: 'get',
- module: 'users',
- options: {
- // [!code ++]
- cache: 'force-cache', // Uses Next.js cache
- },
-});
+import { coreFetcher } from '@vitnode/core/lib/fetcher/core';
+import { cacheLife, cacheTag } from 'next/cache';
+
+const getAnnouncements = async () => {
+ // [!code ++:3]
+ 'use cache';
+ cacheLife('minutes');
+ cacheTag('announcements');
+
+ const response = await coreFetcher(announcementsModule, {
+ path: '/',
+ method: 'get',
+ module: 'announcements',
+ });
+
+ return await response.json();
+};
```
+
+ Do not reach for `options: { cache: 'force-cache' }` on a `fetcher()` call. It
+ stores the response in the Next.js Data Cache keyed by the request - cookie
+ included - with no expiry, so the visitor keeps being served their own data
+ from whenever it was first fetched. Use React's `cache()` for those instead.
+
+
+See [Cache](/docs/dev/cache) for how to decide which of the two a given route is,
+and how to expire what you store.
+
### Cookie Management
When working with authentication or sessions, you might need to handle cookies. React Server Components have special considerations for cookie handling:
diff --git a/apps/docs/content/docs/dev/meta.json b/apps/docs/content/docs/dev/meta.json
index 47d11afc0..b2e0d3b9a 100644
--- a/apps/docs/content/docs/dev/meta.json
+++ b/apps/docs/content/docs/dev/meta.json
@@ -14,6 +14,7 @@
"database",
"content-engine",
"fetcher",
+ "cache",
"working-with-users",
"i18n",
"search",
diff --git a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/reset-password/page.tsx b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/reset-password/page.tsx
index 02f57b3b6..e61670367 100644
--- a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/reset-password/page.tsx
+++ b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/login/reset-password/page.tsx
@@ -4,6 +4,15 @@ import { getTranslations } from "next-intl/server";
import { PasswordResetView } from "@vitnode/core/views/auth/password-reset/password-reset-view";
+// instant = false: kept on purpose. The page is only meaningful on an install
+// with an email adapter, and `PasswordResetView` calls `notFound()` when there
+// is none - so the response status depends on a read only the API can answer.
+// That read cannot be prerendered, and behind a `` boundary it would
+// land after the fallback had already committed the response to 200, leaving
+// crawlers, caches and monitoring with a successful reset-password page whose
+// body says not-found. The route blocks so the status is decided first.
+export const instant = false;
+
export const generateMetadata = async (): Promise => {
const t = await getTranslations("core.auth.reset_password");
diff --git a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/settings/layout.tsx b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/settings/layout.tsx
index 0e3b8c17b..b1bf02a42 100644
--- a/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/settings/layout.tsx
+++ b/apps/docs/src/app/[locale]/(main)/(plugins)/(vitnode-core)/settings/layout.tsx
@@ -9,11 +9,6 @@ export const metadata: Metadata = {
},
};
-// instant = false: kept on purpose. Every route under this layout is gated on a
-// signed-in user in `LayoutSettings`, which calls `notFound()` when there isn't
-// one. Moving that read inside `` would turn a real 404 into a 200
-// shell that later swaps to a not-found body, so the gate stays where it is and
-// the segment is allowed to block.
export const instant = false;
export default function Layout(
diff --git a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/[id]/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/[id]/page.tsx
index a5f1d66ae..df6ff9e14 100644
--- a/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/[id]/page.tsx
+++ b/apps/docs/src/app/[locale]/admin/(auth)/(plugins)/(vitnode-core)/core/users/[id]/page.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from "next/dist/types";
import { getTranslations } from "next-intl/server";
import dynamic from "next/dynamic";
+import { connection } from "next/server";
import React from "react";
import { adminModule } from "@vitnode/core/api/modules/admin/admin.module";
@@ -46,18 +47,38 @@ export const generateMetadata = async ({
};
};
-export default async function Page({
+/**
+ * `generateMetadata` puts the user's name in the title, which needs a fetch that
+ * cannot be cached - `fetcher` forwards the request's cookies and `use cache`
+ * cannot enclose a runtime read. This marks the route as intentionally partly
+ * dynamic so the metadata is allowed to be, while the body still prerenders.
+ */
+const DynamicMarker = async () => {
+ await connection();
+
+ return null;
+};
+
+const ShowUserAdmin = async ({
params,
}: {
params: Promise<{ id: string }>;
-}) {
+}) => {
const { id } = await params;
+ return ;
+};
+
+export default function Page({ params }: { params: Promise<{ id: string }> }) {
return (
+
+
+
+
}>
-
+
diff --git a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/core/staff/admins/edit/[id]/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/core/staff/admins/edit/[id]/page.tsx
index 2dbb8c87e..49b3a048d 100644
--- a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/core/staff/admins/edit/[id]/page.tsx
+++ b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/core/staff/admins/edit/[id]/page.tsx
@@ -1,11 +1,16 @@
+import React from "react";
+
import { BreadcrumbStaffEditAdmin } from "@vitnode/core/views/admin/layouts/breadcrumb/breadcrumb-staff-edit-admin";
+import { BreadcrumbSkeleton } from "@vitnode/core/views/breadcrumb/breadcrumb-render";
-export default async function BreadcrumbSlot({
+export default function BreadcrumbSlot({
params,
}: {
params: Promise<{ id: string }>;
}) {
- const { id } = await params;
-
- return ;
+ return (
+ }>
+
+
+ );
}
diff --git a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/core/staff/moderators/edit/[id]/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/core/staff/moderators/edit/[id]/page.tsx
index 753c353b2..75e151b69 100644
--- a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/core/staff/moderators/edit/[id]/page.tsx
+++ b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/core/staff/moderators/edit/[id]/page.tsx
@@ -1,11 +1,16 @@
+import React from "react";
+
import { BreadcrumbStaffEditAdmin } from "@vitnode/core/views/admin/layouts/breadcrumb/breadcrumb-staff-edit-admin";
+import { BreadcrumbSkeleton } from "@vitnode/core/views/breadcrumb/breadcrumb-render";
-export default async function BreadcrumbSlot({
+export default function BreadcrumbSlot({
params,
}: {
params: Promise<{ id: string }>;
}) {
- const { id } = await params;
-
- return ;
+ return (
+ }>
+
+
+ );
}
diff --git a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/core/users/[id]/page.tsx b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/core/users/[id]/page.tsx
index 59050d00a..69bdd70e3 100644
--- a/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/core/users/[id]/page.tsx
+++ b/apps/docs/src/app/[locale]/admin/(auth)/@breadcrumb/core/users/[id]/page.tsx
@@ -1,11 +1,16 @@
+import React from "react";
+
import { BreadcrumbUserAdmin } from "@vitnode/core/views/admin/layouts/breadcrumb/breadcrumb-user-admin";
+import { BreadcrumbSkeleton } from "@vitnode/core/views/breadcrumb/breadcrumb-render";
-export default async function BreadcrumbSlot({
+export default function BreadcrumbSlot({
params,
}: {
params: Promise<{ id: string }>;
}) {
- const { id } = await params;
-
- return ;
+ return (
+ }>
+
+
+ );
}
diff --git a/apps/docs/src/app/[locale]/admin/(auth)/layout.tsx b/apps/docs/src/app/[locale]/admin/(auth)/layout.tsx
index a6e0ca312..83191e110 100644
--- a/apps/docs/src/app/[locale]/admin/(auth)/layout.tsx
+++ b/apps/docs/src/app/[locale]/admin/(auth)/layout.tsx
@@ -5,8 +5,6 @@ import {
import { vitNodeConfig } from "@/vitnode.config";
-export const instant = false;
-
export default function Layout(props: AdminLayoutProps) {
return ;
}
diff --git a/apps/docs/src/app/[locale]/layout.client.tsx b/apps/docs/src/app/[locale]/layout.client.tsx
index 8ae133f18..e10eb48fc 100644
--- a/apps/docs/src/app/[locale]/layout.client.tsx
+++ b/apps/docs/src/app/[locale]/layout.client.tsx
@@ -1,16 +1,43 @@
"use client";
-import { useParams } from "next/navigation";
+import { useParams, useServerInsertedHTML } from "next/navigation";
import { useLayoutEffect } from "react";
/**
- * Keeps the docs section class (`dev`, `guides`, `plugins`, `ui`) on `` in
- * sync with the route. The class sets `--color-fd-primary`, so it has to live on
- * `` - an element that cannot be wrapped in ``.
+ * Paints the docs section class (`dev`, `guides`, `plugins`, `ui`) on ``
+ * before first paint.
*
- * First paint is handled by the inline script in the root layout, which derives
- * the same class from `location.pathname` before anything renders. This takes
- * over from there, so client navigations between sections repaint correctly.
+ * The class sets `--color-fd-primary`, so it has to sit on an ancestor of the
+ * whole shell. `` cannot be wrapped in ``, so the class cannot
+ * come from a `useParams()` read during prerendering without making every route
+ * block - deriving it from `location.pathname` here keeps the shell static.
+ *
+ * `useServerInsertedHTML` keeps the tag out of React's render tree: it is
+ * emitted into the streamed `` on the server and is a no-op on the
+ * client, so React never renders a `