Caching
Cache VitNode Content Engine public reads with TanStack Query in the app, and expire them in a separate front end with cache tags and content.revalidateOrigins.
Without Redis, the Content Engine does not cache public reads. Every request to a public API route queries Postgres, and the response carries no Cache-Control header:
curl -si "http://localhost:3000/api/@vitnode/example/content/articles?first=1" | head -4HTTP/1.1 200
access-control-allow-credentials: true
content-type: application/json
cross-origin-opener-policy: same-originSo a published article shows up on the next request, with nothing to expire.
With Redis (REDIS_URL), the server also keeps the public list and detail responses for up to 60 seconds. Each entry is keyed by the parsed query, the resolved locale and a generation number per content type. Every content write bumps that generation, so a write still shows up on the next request and a stale entry is never read. A record whose future publishedAt comes due without a write can take up to that minute to appear. The language list and the container tree behind includeDescendants are cached the same way. There is nothing to configure.
Only responses that look the same to every reader are cached. A hidden record shown to its author, editor or moderator (on its page or in a list), a preview link and the delivery routes always go to Postgres.
Add caching where it pays off:
| Where the content is read | Cache with | Expire it with |
|---|---|---|
| A page of your VitNode app | TanStack Query, warmed in the loader | Its staleTime, or invalidateQueries after a write |
| The HTML of a public page in your app | Page cache | Automatic: every content write invalidates it |
| A separate front end with its own cache | That front end's tag cache | content.revalidateOrigins and the cache tags below |
| Your own Hono route | c.get("cache") | c.get("cache").delete(key) after a write |
Cache a public list in the app
Wrap the fetcher call in queryOptions and warm it in the route loader, exactly as in Cache app data. Use defineRoute from @vitnode/core/tanstack/plugin-routes, because its loader context has the queryClient:
import { queryOptions } from '@tanstack/react-query'
import { fetcher } from '@vitnode/core/tanstack/fetcher'
import { defineRoute } from '@vitnode/core/tanstack/plugin-routes'
export const latestArticlesQuery = () =>
queryOptions({
queryKey: ['@vitnode/example', 'articles', 'latest'],
queryFn: async () => {
const response = await fetcher({
plugin: '@vitnode/example',
method: 'get',
module: 'content/articles',
path: '/',
args: { query: { first: 5 } },
})
if (response.status !== 200) {
throw new Error(`Loading articles answered ${response.status}.`)
}
const { edges } = await response.json()
return edges
},
})
export const route = defineRoute({
load: async ({ context }) => {
await context.queryClient.query({
...latestArticlesQuery(),
staleTime: 60_000,
})
},
})Each server render starts with an empty query client, so a full page load is always fresh. The staleTime only decides how long a visitor's open tab reuses the list during client-side navigation. staleTime: "static" keeps it until the tab reloads.
Publishing in the AdminCP refreshes the AdminCP's own list, item and search queries. It cannot reach a visitor's browser, so pick a staleTime you are happy to wait out. For your own write routes, call queryClient.invalidateQueries({ queryKey: ["@vitnode/example", "articles"] }) after the write.
Public routes also send an x-vitnode-cache-tags header naming the record they read, such as content:blog.post:item:3, or the content type for a list. The page cache stores a page with these tags and invalidates it after every write, hard for a hide, delete, unpublish or slug change. How cached pages stay fresh explains the rules.
Cache tags
When a front end caches public reads under tags, it needs to know which tags a write affects. @vitnode/core/content exports pure string builders for that. They read nothing and need no server:
| Builder | Example for example.article |
|---|---|
contentPublicListTag(typeId, locale?) | content:example.article:list |
contentPublicItemTag(typeId, id, locale?) | content:example.article:item:3 |
contentPublicSlugTag(typeId, slug, locale?) | content:example.article:slug:hello-content-engine |
contentDeliveryTag(typeId, id, locale?) | content:example.article:delivery:3 |
contentDeliveryRedirectTag(typeId, slug, locale?) | content:example.article:redirect:hello-content-engine |
contentDeliverySitemapTag(typeId, locale?) | content:example.article:sitemap |
A locale goes after the kind, so a localized type's Polish list is content:example.localized-article:list:pl. An id goes in as text, so a uuid or bigint record's item tag carries its exact UUID or decimal string.
contentInvalidationTags turns a description of one write into the exact tags to expire. Publishing article 3 returns all six tags above:
import { contentInvalidationTags } from '@vitnode/core/content'
const tags = contentInvalidationTags({
contentTypeId: 'example.article',
id: 3,
slugs: ['hello-content-engine'],
wasPublic: false,
isPublic: true,
delivery: { sitemap: { contentChanged: true, indexChanged: true } },
})A write to a record that was a draft before and after returns [], because no public reader ever saw it. On a slug change, pass both slugs so the old URL stops resolving and the new one starts.
Notify a separate front end
If another app renders your content and caches it, list its origin in the API config:
export const vitNodeApiConfig = buildApiConfig({
content: {
revalidateOrigins: ['https://www.example.com'],
},
})VitNode then sends a POST to {origin}/api/vitnode/content/revalidate in these cases:
| Change | Notifies origins |
|---|---|
| A scheduled publish or unpublish runs | Yes |
| A record is hidden or unhidden | Yes, when that changes what the public can see |
| A publish, unpublish, edit or delete from the AdminCP or the API | No |
Hiding a draft notifies nobody, because nothing public changed. For the changes that send nothing, a front end that caches must expire entries on its own timer, or from a listener for the content events.
The request carries:
Authorization: Bearer <CRON_SECRET>, the same secret that runs cron jobs.x-vitnode-timestamp, the send time in milliseconds.- A JSON body with the
contentInvalidationTagsinput plusmode, which is always"immediate"for a scheduled run, a hide and an unhide.
The secret travels with the request, so outside development each origin must use https. Plain http is allowed only for localhost and 127.0.0.1. VitNode sends nothing while CRON_SECRET is unset or still the default. It tries each origin twice and does not retry a 403, which it reads as a wrong secret.
Build the revalidate endpoint
The endpoint lives in your front end, not in VitNode. Before you expire anything, check the bearer in constant time and refuse a timestamp more than 5 minutes off, so a captured request cannot be replayed later:
import { createHash, timingSafeEqual } from 'node:crypto'
const MAX_SKEW_MS = 5 * 60 * 1000
const digest = (value: string) => createHash('sha256').update(value).digest()
export const isTrustedRevalidation = (request: Request): boolean => {
const secret = process.env.CRON_SECRET
if (!secret) return false
const bearer = /^Bearer (.+)$/.exec(
request.headers.get('authorization') ?? '',
)?.[1]
if (!bearer || !timingSafeEqual(digest(bearer), digest(secret))) return false
const sentAt = Number(request.headers.get('x-vitnode-timestamp'))
return Number.isFinite(sentAt) && Math.abs(Date.now() - sentAt) <= MAX_SKEW_MS
}Hashing both values first gives timingSafeEqual two buffers of the same length. Answer 403 when the check fails. Otherwise pass the body to contentInvalidationTags, expire those tags in your cache and answer with any 2xx.