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.
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. 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 |
| 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";
import { z } from "zod";
const zodArticleCard = z.object({
excerpt: z.string().nullable(),
slug: z.string(),
title: z.string(),
});
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 z.array(zodArticleCard).parse(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.
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.
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 when a scheduled publish or unpublish runs. Publishing by hand in the AdminCP sends nothing today, so a front end that caches must also expire entries on its own timer.
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.
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.