Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions apps/docs/content/docs/dev/advanced/redis.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ REDIS_URL=redis://localhost:6379
REDIS_PASSWORD=root
```

<Callout type="info" title="Both processes read these">
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.
</Callout>

<Callout type="warn" title="Change the password in production">
`root` is a convenience default for local development. Set a strong
`REDIS_PASSWORD` in production.
Expand Down Expand Up @@ -189,6 +195,12 @@ export const postsRoute = buildRoute({

## Caching database queries

<Callout type="info" title="Two caches, one request">
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.
</Callout>

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.
Expand Down Expand Up @@ -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.
</Callout>

## 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.

<Callout type="info" title="Safe without Redis">
Without Redis the read misses, the write is a no-op, and every request resolves
permissions from the database exactly as before.
</Callout>

## 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);
```

<Callout type="warn" title="The web process reads the environment directly">
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.
</Callout>

### What each handler covers

VitNode registers two, because Next has two caches and they are not the same one:

<TypeTable
type={{
"cacheHandlers.default": {
description:
'Backs the `use cache` directive. Entries are stored whole in Redis, so they survive a restart and every instance reads the same one.',
type: "use cache",
},
cacheHandler: {
description:
"Backs the fetch Data Cache and the prerender store. `fetch` responses go to Redis; prerendered pages stay on each instance's disk, where the build put them.",
type: "Data Cache + ISR",
},
}}
/>

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.

<Callout type="info" title="Opting out">
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 })`.
</Callout>

## Rate limiting with Redis

When `redis` is configured, the [rate limiter](/docs/dev/advanced/rate-limiter)
Expand Down
183 changes: 183 additions & 0 deletions apps/docs/content/docs/dev/cache.mdx
Original file line number Diff line number Diff line change
@@ -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();
Comment on lines +28 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move connection outside the cached function

Developers copying this documented example will call connection() from inside a "use cache" scope, where Next.js runtime request APIs are not allowed, so the example fails instead of producing cached public data. The implementation in views/search/fetch-feed.ts already demonstrates the required ordering by awaiting connection() in the uncached wrapper before calling the cached function; the documentation should show the same split.

Useful? React with 👍 / 👎.


const res = await coreFetcher(announcementsModule, {
module: 'announcements',
path: '/',
method: 'get',
args: { query: { lang: locale } },
});

return await res.json();
};
```

Render cached data inside a `<Suspense>` boundary in your page:

```tsx title="page.tsx"
<React.Suspense fallback={<Skeleton className="h-64 w-full" />}>
<Announcements />
</React.Suspense>
```

<Callout type="info" title="cacheLife Presets">
Common presets: `'seconds'`, `'minutes'`, `'hours'`, `'days'`, `'weeks'`, `'max'`, or custom `{ stale, revalidate, expire }`.
</Callout>

### 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";

<TypeTable
type={{
"updateTag(tag)": {
description:
"Server Actions only. Instantly purges the tag and refreshes the current page (read-your-writes semantics).",
type: "void",
},
"revalidateTag(tag, profile)": {
description:
"Route Handlers, webhooks, or background jobs. Profile is required for SWR: revalidateTag('posts', 'max') or inline { revalidate: 3600 }.",
type: "void",
},
"refresh()": {
description:
"Server Actions only. Refreshes uncached dynamic data on the page without touching the cache.",
type: "void",
},
}}
/>

### 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();
};
```

<Callout type="info" title="Content Engine">
Content Engine entries are automatically tagged and revalidated for you via `revalidateContent`. See [Content Engine Caching](/docs/dev/content-engine/public-api-and-caching).
</Callout>

---

## 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.

<Callout type="warn" title="The web process needs the env vars">
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.
</Callout>

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}`);
```

<Callout type="info" title="Optional Redis">
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).
</Callout>
42 changes: 32 additions & 10 deletions apps/docs/content/docs/dev/fetcher.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};
```

<Callout type="warn" title="Not for per-visitor responses">
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.
</Callout>

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:
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/dev/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"database",
"content-engine",
"fetcher",
"cache",
"working-with-users",
"i18n",
"search",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Suspense>` 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<Metadata> => {
const t = await getTranslations("core.auth.reset_password");

Expand Down
Loading