Vercel Rendering and Caching — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited Vercel Rendering and Caching (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
This is the rendering-and-caching stage of shipping a Next.js app on Vercel. By the time you are here the project deploys (vercel-deploy-pipeline) and its env vars are set (vercel-env-management); now you decide, per route, where it runs, how it renders, and how long its output lives — then you make invalidation deliberate instead of accidental. Wrong defaults here are the difference between a site that serves from cache in single-digit milliseconds and one that re-runs a function on every request and still serves stale data.
The single most common mistake in 2026 is carrying forward 2023-era advice. Two defaults flipped: Edge Functions are deprecated — Fluid Compute (full Node.js) is the default runtime — and `unstable_cache` is replaced by Next.js 16 Cache Components. This skill encodes the current defaults; correct any older instinct against it.
Run these in order. Pick the runtime before the render mode, and the render mode before reaching for the imperative cache — most routes never need step 4.
Default every function and your middleware to Fluid Compute on Node.js. As of 2026 this is the platform default and Edge Functions are deprecated — do not reach for export const runtime = 'edge'.
What Fluid Compute gives you, and why the old Edge tradeoff is gone:
npm ecosystem, native modules, and full Web APIs — no Edge runtime subset to fight.
invocations and overlaps I/O-bound work, so the cold-start penalty that once pushed latency-sensitive code to Edge is largely gone.
Node.js. Billing is Active CPU time + provisioned memory + invocations — you are billed for CPU actually burned, not wall-clock GB-seconds, so an instance parked on await is cheap.
work fits without plan gymnastics.
Enable it in vercel.ts (recommended in 2026; vercel.json still works):
// vercel.ts — import from '@vercel/config'
import type { VercelConfig } from '@vercel/config/v1';
export const config: VercelConfig = {
fluid: true,
};Reach for the Edge runtime only for a genuinely global, ultra-thin redirect/rewrite that must run in every region with no Node dependency — and even then, confirm it against current docs first, because it is on the deprecation path. For request interception (rewrites, redirects, personalization) keep it in Node.js middleware. Picking a runtime to dodge a cold function on the request path is the wrong fix — the answer there is usually to cache the response (steps 2–4) so the function does not run at all.
Decide per route. Default to the most static option the data allows, and add dynamism only where the content actually demands it.
visitor (marketing, docs). Served from the Edge Network; the function never runs on a request. Cheapest and fastest.
schedule or on demand**. The right default for content that changes but not per-request: blogs, product pages, catalogs. Works on Next.js, SvelteKit, Nuxt, and Astro. The first request after the window serves stale and regenerates in the background (stale-while-revalidate), so users never wait on a rebuild.
with <Suspense>. Use when the page is personalized or live but you still want a fast first paint. Fluid Compute's long timeout and instance reuse make streaming cheap.
served instantly from the edge, with dynamic holes streamed in per request. Reach for it when one page is mostly static but has a few personalized regions (a product page with a static body and a live "recommended for you" rail). In Next.js 16 PPR is part of Cache Components — what is not wrapped in 'use cache' is the dynamic hole.
Next.js ISR by interval (App Router):
// Revalidate this route's data every 60s; first request after expiry
// serves stale and regenerates in the background.
export default async function Page() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 60, tags: ['posts'] },
});
return <Posts data={await res.json()} />;
}ISR on the other frameworks is config-driven via routeRules (Nuxt/Nitro) or the adapter isr option (SvelteKit) — isr: 60 for an interval, isr: true for cache forever, isr: false for always-fresh. Verify the exact key against the framework doc; the concept (interval, on-demand, forever) is identical across all four.
'use cache' and tagsFor App Router on Next.js 16, Cache Components replace `unstable_cache`. If you still have unstable_cache, migrating off it is the task — see the Do NOT section.
Turn it on in config, then cache at the function or component boundary with the 'use cache' directive (use 'use cache: remote' for the Vercel Runtime Cache so the entry is shared across regions and invocations):
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = { cacheComponents: true };
export default nextConfig;import { cacheLife, cacheTag } from 'next/cache';
export default async function Page() {
return <Products data={await getProducts()} />;
}
async function getProducts() {
'use cache: remote';
cacheTag('products'); // attach a tag for on-demand invalidation
cacheLife({ expire: 3600 }); // and/or a time budget (1 hour)
const res = await fetch('https://api.example.com/products');
return res.json();
}The four primitives, and when each fires:
Plain 'use cache' caches at the appropriate layer; : remote targets the cross-region Runtime Cache. Anything not wrapped becomes a dynamic PPR hole.
{ expire: 3600 }), the ISR intervalexpressed at the cache boundary.
Tag generously; tags are how you avoid time-based-only invalidation.
regenerates in the background (stale-while-revalidate). The safe default.
refresh: use it inside a mutating Server Action when the user must see their own change reflected synchronously (e.g. after editing their profile). It replaces the old pattern of revalidating and hoping the next paint is fresh.
Wire invalidation to the mutation that causes it — call the tag function in the same Server Action that writes the data. A page that reads cacheTag('products') and a createProduct action that calls revalidateTag('products') is a complete, correct loop. Time-based cacheLife is the fallback for data you cannot tag.
When you need an explicit key/value cache outside the framework's render path — in a plain Function, in Routing Middleware, or to share a computed value across Functions and Builds — use the Runtime Cache via @vercel/functions. It is a per-region cache with global tag-based invalidation.
import { getCache } from '@vercel/functions';
export default {
async fetch(request: Request) {
const cache = getCache();
const hit = await cache.get('blog:list');
if (hit) return Response.json(hit);
const data = await (await fetch('https://api.example.com/blog')).json();
await cache.set('blog:list', data, { ttl: 3600, tags: ['blog'] });
return Response.json(data);
},
};Invalidate by tag from anywhere (a webhook handler, a Server Action). Two shapes, different semantics:
import { getCache, invalidateByTag } from '@vercel/functions';
// Hard expire: drop entries for these tags now. Propagates globally.
await getCache().expireTag('blog');
// Soft invalidate: mark stale; entries revalidate in the background on next read.
await invalidateByTag('blog');Use expireTag when stale data is unacceptable (pricing, inventory) and invalidateByTag when a brief background-refresh window is fine (it keeps latency flat under load). Reach for this layer only when the framework cache (step 3) does not reach your code — most app rendering should stay in 'use cache'.
For each route, lock the three choices and write them down so the next engineer inherits the intent, not a guess. Run cache_plan.js (below) to turn the routes into a table, then paste it into the rendering-plan template.
A rendering-and-caching plan is done only when all hold:
current docs; everything else is Fluid Compute / Node.js, and middleware is Node.js.
'use cache' /cacheLife / cacheTag.
the matching revalidateTag/updateTag** (or expireTag/invalidateByTag for the Runtime Cache). No write path leaves a tag stale forever.
revalidateTag everywhere else. The choice is deliberate, not coincidental.
where content is genuinely per-request.
assumption and verify under load.
deprecated as of 2026; Fluid Compute on Node.js is the default and removes the cold-start reason people fled to Edge.
Node.js 24 LTS and the default function timeout is 300s on all plans.
Components ('use cache'); a per-instance Map is not shared across Fluid instances or regions and silently serves inconsistent data.
and invalidate on write; cacheLife is the fallback for untaggable data, not the primary strategy.
revalidateTag when the acting user must see their own change on thenext paint** — that is exactly what updateTag is for. Conversely, do not reach for updateTag on a background webhook where stale-while-revalidate is fine.
response so the function does not run, rather than running the same slow work closer to the user.
use Marketplace storage (Neon Postgres, Upstash Redis) plus Vercel Blob and Edge Config. The Runtime Cache is a cache, not a database; never treat it as the source of truth.
Self-contained Node script — no dependencies. Save as cache_plan.js, list your routes in the routes block, and run node cache_plan.js. It recommends a render mode and the cache wiring per route and flags any route still set to a deprecated runtime.
// Vercel rendering & caching planner. Edit routes, then: node cache_plan.js
const routes = [
// perVisitor: does output differ per user? mutates: is there a clear write event?
// freshnessSeconds: how stale may it be? (0 = must be live) runtime: 'node' | 'edge'
{ path: '/', perVisitor: false, mutates: false, freshnessSeconds: 86400, runtime: 'node' },
{ path: '/blog/[slug]', perVisitor: false, mutates: true, freshnessSeconds: 3600, runtime: 'node' },
{ path: '/product/[id]',perVisitor: true, mutates: true, freshnessSeconds: 300, runtime: 'node' },
{ path: '/dashboard', perVisitor: true, mutates: true, freshnessSeconds: 0, runtime: 'node' },
{ path: '/edge-redirect',perVisitor: false,mutates: false, freshnessSeconds: 86400, runtime: 'edge' },
];
function plan(r) {
let mode, cache;
if (r.freshnessSeconds === 0 && r.perVisitor) {
mode = 'Dynamic + streaming (<Suspense>)';
cache = "no 'use cache' on the live region";
} else if (r.perVisitor) {
mode = 'PPR (static shell + dynamic hole)';
cache = "'use cache' the shell; stream the per-user hole";
} else if (r.freshnessSeconds >= 86400 && !r.mutates) {
mode = 'Fully static (prerender)';
cache = 'no revalidation needed';
} else {
mode = 'ISR / Cache Components';
cache = `'use cache'; cacheLife({ expire: ${r.freshnessSeconds} })`;
}
const tag = r.path.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').toLowerCase() || 'root';
const invalidate = r.mutates
? `cacheTag('${tag}') + ${r.freshnessSeconds === 0 ? 'updateTag' : 'revalidateTag'}('${tag}') on write`
: 'time-based only';
const flag = r.runtime === 'edge'
? 'FLAG: edge runtime is deprecated — move to Fluid Compute (node)'
: 'ok (Fluid Compute / Node.js)';
return { mode, cache, invalidate, flag };
}
for (const r of routes) {
const p = plan(r);
console.log(r.path);
console.log(' render: ', p.mode);
console.log(' cache: ', p.cache);
console.log(' invalidate:', p.invalidate);
console.log(' runtime: ', p.flag);
console.log('');
}With the routes above the script prints:
/
render: Fully static (prerender)
cache: no revalidation needed
invalidate: time-based only
runtime: ok (Fluid Compute / Node.js)
/blog/[slug]
render: ISR / Cache Components
cache: 'use cache'; cacheLife({ expire: 3600 })
invalidate: cacheTag('blog-slug') + revalidateTag('blog-slug') on write
runtime: ok (Fluid Compute / Node.js)
/product/[id]
render: PPR (static shell + dynamic hole)
cache: 'use cache' the shell; stream the per-user hole
invalidate: cacheTag('product-id') + revalidateTag('product-id') on write
runtime: ok (Fluid Compute / Node.js)
/dashboard
render: Dynamic + streaming (<Suspense>)
cache: no 'use cache' on the live region
invalidate: cacheTag('dashboard') + updateTag('dashboard') on write
runtime: ok (Fluid Compute / Node.js)
/edge-redirect
render: Fully static (prerender)
cache: no revalidation needed
invalidate: time-based only
runtime: FLAG: edge runtime is deprecated — move to Fluid Compute (node)Read it: the marketing home is fully static, the blog is plain ISR with on-demand revalidateTag on publish, the product page is PPR (static body, streamed per-user rail), and the dashboard is live-streamed with updateTag so a user's own edit shows immediately. The /edge-redirect route is flagged because it still targets the deprecated Edge runtime — move it to Fluid Compute. Change the inputs to match your routes; the point is to make every route's three choices explicit.
Copy this, run cache_plan.js, and paste its rows in. One row per route — this is the artifact the next engineer reads to understand why each route renders the way it does.
VERCEL RENDERING & CACHING PLAN. [FILL: project]. [FILL: month/year]
GLOBAL
Runtime default: Fluid Compute / Node.js 24 LTS (edge = deprecated)
Middleware runtime: Node.js
Cache Components on: [FILL: yes/no] (cacheComponents: true in next.config)
Storage: [FILL: Neon / Upstash / Blob / Edge Config]
PER ROUTE
ROUTE RENDER MODE CACHE WIRING INVALIDATE ON
[FILL] [static/ISR/PPR/stream] [FILL: use cache + cacheLife] [FILL: which write -> tag fn]
[FILL] [FILL] [FILL] [FILL]
(add rows until every route is listed)
RUNTIME CACHE (only if used outside the framework path)
Keys/tags: [FILL]
Invalidation: [FILL: expireTag (hard) / invalidateByTag (soft)] from [FILL: which handler]
DEFERRALS
Env vars: vercel-env-management
Deploy/promote: vercel-deploy-pipeline
Core Web Vitals/bundle: next-on-vercel-perfThe two decisions are independent but both default toward less work on the request path. Runtime is where code runs when it runs; render mode is whether code runs at all per request. In 2026 the runtime default is Fluid Compute on Node.js — full Node, instance reuse to cut cold starts, same regions and price band, Active-CPU billing, and a 300s default timeout — and Edge Functions are deprecated. So the render mode carries the performance weight: a fully static or ISR route serves from the Edge Network and the function never executes per request, which beats any runtime choice. Climb the static ladder first (static -> ISR -> PPR -> streaming -> fully dynamic) and only descend as far as the data forces you. PPR is the lever that lets one page be mostly static while still showing per-user content: a prerendered shell from the edge with dynamic holes streamed in, where the holes are precisely the parts not wrapped in 'use cache'.
Caching is easy; invalidation is where teams ship stale data. The rule: every cached read carries a tag, and every write that changes that data calls the matching invalidation in the same code path. Tag taxonomy beats time budgets — cacheLife is the fallback for data with no clear mutation point, not the primary strategy.
Pick the invalidation verb by who needs freshness and how fast:
in the background. Stale-while-revalidate. The default for content other people edit (a published post, an updated price seen by all visitors).
mutating Server Action. Use it when the acting user must see their own change on the very next render (profile edit, settings save). Without it you get the classic "I saved but it still shows the old value" bug.
@vercel/functions) — hard global expirywithin ~300ms across regions. Use when stale is unacceptable (inventory, pricing).
background on next read. Keeps latency flat under load when a brief stale window is acceptable.
If you are migrating from unstable_cache: replace the wrapper with a 'use cache' function, move its tags to cacheTag(...), move its revalidate to cacheLife({ expire }), and keep calling revalidateTag from the mutation — the invalidation call site does not change, only the caching declaration does.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.