Logo VitNode

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 -4
HTTP/1.1 200
access-control-allow-credentials: true
content-type: application/json
cross-origin-opener-policy: same-origin

So a published article shows up on the next request, with nothing to expire. Add caching where it pays off:

Where the content is readCache withExpire it with
A page of your VitNode appTanStack Query, warmed in the loaderIts staleTime, or invalidateQueries after a write
A separate front end with its own cacheThat front end's tag cachecontent.revalidateOrigins and the cache tags below
Your own Hono routec.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:

latest-articles.tsx
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:

BuilderExample 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:

revalidate.ts
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:

apps/api/src/vitnode.api.config.ts
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 contentInvalidationTags input plus mode, 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:

is-trusted-revalidation.ts
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.