Skip to main content

Caching, SSG, and ISR

Function cache​

import { cached, revalidatePath, revalidateTag } from '@nessframework/cache';

export const getPosts = cached(() => db.post.findMany(), {
key: 'posts',
life: 'minutes',
tags: ['posts'],
path: '/posts',
});

await revalidateTag('posts');
await revalidatePath('/posts');

Profiles include seconds, minutes, hours, days, max, and default. Concurrent calls are deduplicated. Expired values are regenerated; stale values are served while one background refresh runs.

Server-side fetch() plugs into the same cache: identical GETs inside one request share one network call, and fetch(url, {next: {revalidate: 60, tags: ['posts']}}) stores the response through whatever adapter is configured β€” invalidated by the same revalidateTag/revalidatePath. noStore() (or await connection()) from @nessframework/core/server takes a whole response out of the page cache. See Next.js parity.

The 'use cache' directive​

The same memoization as cached(), written as Next 16 writes it β€” a directive instead of a wrapper:

import { cacheLife, cacheTag } from '@nessframework/cache';

async function getPosts(limit: number) {
'use cache';
cacheLife('minutes');
cacheTag('posts');
return db.post.findMany({ take: limit });
}

A server-side function whose body opens with 'use cache' is compiled into a cached one, keyed by function identity plus arguments. cacheLife() takes a profile name or a {stale, revalidate, expire} object; cacheTag() marks the entry for revalidateTag(). A module-level 'use cache' covers every exported function in the file. Two honest limits: the function must be async and named (an anonymous default export has no binding to wrap), and the directive is server-only β€” a client bundle that reaches one is a build error, same as importing a .server module.

Adapters​

MemoryCacheAdapter is the default and is process-local: a second instance keeps its own copy, and revalidateTag on one instance does not reach the others. Anything running more than one process needs a shared adapter.

Configure one in the server section of ness.config.mjs:

import { defineNessConfig } from '@nessframework/router';

export default defineNessConfig({
server: {
cache: { adapter: 'filesystem', directory: '.ness/cache' },
},
});
AdapterShared acrossNeedsUse it when
memorynothingβ€”development, single process
filesystemprocesses on one host, restartsa writable directoryone container, clustered Node, no external service
sqliteprocesses on one host, restartsNode.js 22.5+ (node:sqlite)same as above, with indexed invalidation
redisevery instancea Redis client you supplymore than one host or replica

Redis takes the client from your config rather than bundling one, so connection, TLS, and pooling stay yours:

import { createClient } from 'redis';

const client = await createClient({ url: process.env.REDIS_URL }).connect();

export default defineNessConfig({
server: {
cache: { adapter: 'redis', client, prefix: 'app:cache:' },
},
});

Tag and path invalidation​

Adapters expose keysByTag and keysByPath, so revalidateTag('posts') resolves the affected keys from an index instead of reading every cached entry. An adapter of your own may omit them β€” the cache falls back to a scan, which stays correct but costs one read per entry.

Local tier​

A shared store turns every cache hit into a network round trip. Set local to keep an in-process tier in front of it:

cache: { adapter: 'redis', client, local: true, bus }

The local tier reintroduces the problem the shared store solved: deleting an entry in Redis does not evict the copy another instance already holds in memory. Pass a bus so instances broadcast evictions to each other:

import { createRedisInvalidationBus } from '@nessframework/cache/tiered';

const bus = createRedisInvalidationBus(client, subscriber);

subscriber must be a separate connection β€” Redis does not allow other commands on a subscribed one. Without a bus, localTtl (5 seconds by default) bounds how long an instance may trust a local copy.

Static generation​

import { defineNessConfig } from '@nessframework/router';

export default defineNessConfig({
router: { prerender: ['/', '/pricing'] },
});

Prerendered HTML and data are emitted into build/client. Other pages use SSR. The Ness production server adds CDN-compatible s-maxage and stale-while-revalidate headers and performs incremental regeneration for anonymous HTML GET requests.

A dynamic page names its own prerender paths with generateStaticParams, exactly as in Next β€” export it from the page or (better) its page.server sibling:

// app/routes/blog/[slug]/page.server.ts
export async function generateStaticParams() {
const posts = await db.post.findMany();
return posts.map(post => ({ slug: post.slug }));
}

The function runs at build time; every param set it returns becomes a concrete path added to prerender, and the manifest records them for dynamicParams: false. A catch-all segment takes an array value, joined with slashes.

output: 'export' in the router config turns the whole build into a static export: ssr switches off, every page is prerendered β€” static paths automatically, dynamic ones through their generateStaticParams β€” and build/client/ is the deployable artifact for any static host. There is no server to start, and no ISR: regeneration means rebuilding.

A response the page cache took part in carries x-ness-cache: MISS on the render that was stored, HIT on a replay of a fresh entry, and STALE on a replay of an entry past its stale age β€” the last of which may have a background refresh running behind it. A request the cache refused carries no such header at all.

A client-side navigation does not ask for the document β€” it asks for the page's data (/page.data, or /page.rsc in RSC mode). Those requests follow the page's own rules: on a page that declares revalidate (or dynamic: 'force-static'), the data request is answered from the same page cache the document is, so repeat navigations stop running the loaders at all. dynamicParams: false closes them over unprerendered params the same way it closes documents.

A page that declares nothing keeps running its loaders per navigation β€” ISR staleness is a bargain a page opts into, never one it wakes up inside of. And the standing refusals below apply unchanged: a credentialed request bypasses the cache, force-dynamic and revalidate = 0 keep the page out entirely.

For the round trips that remain, the client side has its own memory β€” see the client cache.

Draft mode​

app/routes/preview/route.ts
import { enableDraftMode, redirect } from '@nessframework/core/server';

export function GET(request: Request) {
return redirect('/blog/hello', {
headers: { 'set-cookie': enableDraftMode() },
});
}

draftMode(request).isEnabled then tells a loader to fetch unpublished content. The cookie is signed with NESS_DRAFT_SECRET and expires on its own, so possession of the name alone proves nothing β€” and because it is a cookie, the page cache refuses the request from before it is read, which is exactly the behaviour a preview needs.

What the page cache refuses​

A request carrying a cookie or an authorization header bypasses the page cache entirely β€” it is neither answered from the cache nor stored in it. The check happens before the cache is read, not only before it is written: deciding on the way out alone would still let a credentialed request be served another visitor's rendering.

A response carrying set-cookie is never stored, whatever the policy says. The page cache is shared and replays stored headers verbatim, so keeping one would hand the same cookie to every subsequent visitor β€” an anonymous session id, a CSRF token or an experiment bucket minted on a plain GET is enough. The request-side check cannot catch this on its own, because the first visitor arrives without a cookie and is issued one by the render.

That second refusal is enforced around cachePolicy rather than inside it, so a project supplying its own policy cannot reintroduce the leak by forgetting the check.

If you replace cachePolicy, consider cacheableRequest alongside it. The first decides what is kept; the second decides whether the cache is touched at all.