Caching, sitemaps and revalidation
Public pages read Convex through a cached client so Next.js can prerender them at build time
and serve them from the CDN. @vexcms/next’s cache and seo entry points, plus a
routes.map function in vex.config.ts, are what make that possible without hand-writing the
plumbing per project.
Setup checklist
Section titled “Setup checklist”The default pnpm create vexcms marketing-site starter already wires all of this — read on to
understand how it works, or to add it yourself to a --bare scaffold or an existing project
once you have a collection of public, slug-addressed documents (pages, posts, docs). Build in
this order — each step is consumed by the one after it:
src/├── lib/vex.ts # 1. the shared cached client├── vex.config.ts # 2. routes.map, for step 6└── app/ ├── layout.tsx # stays free of cookie reads and fetchQuery ├── sitemap.ts # 4. createVexSitemap ├── robots.ts # 5. createVexRobots (no Convex read at all) ├── api/vex/revalidate/route.ts # 6. createVexRevalidateRoute └── (site)/ # or wherever your public routes live ├── layout.tsx # mount shared server components (a theme │ # injector, a footer) here — see the gotcha below ├── page.tsx # 3. reads through `vex`, `export const revalidate` └── [slug]/ └── page.tsx # 7. generateStaticParams + generateMetadatasrc/lib/vex.ts— the one sharedcreateVexServerClientinstance (below).vex.config.ts— addroutes.mapfor the collection driving your public routes (below).- Page routes — read through
vex, callnotFound()on a missing document, and set an inlineexport const revalidateliteral (below). app/sitemap.ts—createVexSitemap, reading the same collection.app/robots.ts—createVexRobots; purely static, no dependency on the others.app/api/vex/revalidate/route.ts—createVexRevalidateRoute, wired tovex.config.ts’sroutes.map. Automatic purging (useVexMutation,RevalidateButton) then works with no further setup.[slug]/page.tsx—generateStaticParams(vexStaticParams) andgenerateMetadata(vexMetadata) for the dynamic segment.
Verify with a real build, not the dev server — next dev never shows which routes are static:
pnpm buildCheck the printed route table for ○/● (static/SSG) on every public route and ƒ only on
/admin, auth routes, and the revalidate handler. A public route still showing ƒ means
something in its render path — the page itself or a shared layout — is still reading a cookie
or calling fetchQuery; see the next section.
Why public pages can prerender at all
Section titled “Why public pages can prerender at all”Two things used to force every route in a VexCMS project to render dynamically on every request, even a page that read no data:
- A cookie read in the root layout.
AuthServerProvidercallsgetToken(), which reads a cookie — and a cookie read anywhere in a route’s render path opts the whole route out of static rendering. Mount it only where it’s actually needed (the/adminroute group), not in a provider shared by every route. fetchQueryfromconvex/nextjshard-codescache: "no-store". It’s built for dynamic routes; using it in a page you want prerendered forces the route dynamic no matter what else the page does.
Removing both is what lets a page with real data fetching build as a static ○ route instead
of a per-request ƒ one.
It’s the whole render tree, not just the page
Section titled “It’s the whole render tree, not just the page”Next decides a route’s rendering mode from everything React renders for that request —
every layout and every component mounted underneath it, not only the route’s own page.tsx.
A server component rendered from a shared layout (a theme-CSS injector, a footer reading a
siteSettings global — anything) that calls fetchQuery or reads a cookie forces every route
under that layout dynamic, even sibling routes whose own page.tsx reads no data at all or
reads only through the cached vex client.
This is easy to miss: the build still succeeds, nothing errors, the route just silently prints
as ƒ instead of ○/● in the route table. Audit every server component reachable from a
route you expect to prerender, not only the page component:
grep -rn "fetchQuery" src/app src/componentsEvery hit outside /admin, auth routes, and route handlers you’ve deliberately kept dynamic is
a candidate. If a route unexpectedly builds dynamic, bisect by swapping its layout for a
trivial passthrough (export default function L({ children }) { return children }) and
rebuilding — if the route table entry flips to static, the layout (or something it renders,
transitively) is the cause, not the page.
The shared read client
Section titled “The shared read client”createVexServerClient (@vexcms/next/cache) wraps Convex’s HTTP client in a React.cache,
so every .query() call it makes within one request dedupes against every other call with the
same arguments — a page component and its generateMetadata reading the same document collapse
into a single round trip. That dedupe only works if every server read shares one client
instance: React.cache memoises per client, so two files that each call
createVexServerClient() build two independent caches that never dedupe with each other, and a
page plus its metadata function end up making the same Convex call twice.
Every project exports exactly one instance, conventionally from src/lib/vex.ts:
import { createVexServerClient } from "@vexcms/next/cache";
import { env } from "~/env.mjs";
export const vex = createVexServerClient({ url: env.NEXT_PUBLIC_CONVEX_URL });Import vex everywhere a server component, layout, or route handler needs to read Convex —
never call createVexServerClient a second time, and never call fetchQuery from
convex/nextjs on a path you want prerenderable.
A public page then reads through it and returns a real 404 for a document that doesn’t exist, rather than a 200 with an empty body:
import { api } from "@convex/_generated/api";import { notFound } from "next/navigation";
import { vex } from "~/lib/vex";
export default async function PublicPage({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; const initialData = await vex.query(api.pages.getBySlug, { slug });
if (initialData.length === 0) { notFound(); }
return <PageContent initialData={initialData} slug={slug} />;}The revalidate window must be an inline literal
Section titled “The revalidate window must be an inline literal”Next reads a route’s export const revalidate by static analysis, before any module in the
route executes — so it accepts only an inline numeric literal, never a member expression or an
imported constant. Measured: both vexConfig.routes.revalidateSeconds and a plain imported
const REVALIDATE_SECONDS = 3600 fail the build with "Invalid segment configuration export detected". revalidateSeconds doesn’t exist as a config option for this reason — a key no
adapter could ever honor is worse than no key, since it reads as configuration while silently
doing nothing.
// Next requires this to be an inline literal — it is read by static analysis// before any module executes, so neither a `vexConfig` member expression nor// an imported constant is accepted ("Invalid segment configuration export").// 3600s = 1 hour: the backstop for writes that never reached the purge route// (Convex dashboard edits, `npx convex import`), since every admin-panel write// purges this path immediately.export const revalidate = 3600;Write this in every route that reads Convex and should serve from the CDN between purges — the number is the ISR backstop, not the primary invalidation mechanism (see below).
Path-based revalidation
Section titled “Path-based revalidation”vex.config.ts’s routes.map maps one written document to the public paths it affects.
resolveTargets calls it once for the document’s before state and once for its after state
on an update, so renaming a page’s slug purges both the old path and the new one without the
map itself having to know it’s part of a rename:
const vexConfig = defineConfig({ // … routes: { map: ({ collection, doc }) => { if (collection !== pages.slug) return []; const { slug } = doc; if (typeof slug !== "string") return []; return [slug === "home" ? "/" : `/${slug}`]; }, },});map is the only key routes takes — there is no revalidateSeconds here; the ISR
window lives as the inline literal in each route instead, where Next requires it.
The revalidate route
Section titled “The revalidate route”createVexRevalidateRoute (@vexcms/next/cache) turns that map into POST /api/vex/revalidate. It’s session-authorized, not secret-authorized: the caller must
already hold a session, and the route re-checks hasPermission against the affected collection
server-side before purging anything — no shared secret or environment variable is involved.
import { api } from "@convex/_generated/api";import { createVexRevalidateRoute } from "@vexcms/next/cache";
import { fetchAuthQuery, getToken } from "~/auth/server";import config from "~/vex.config.server";import { vex } from "~/lib/vex";
export const { POST } = createVexRevalidateRoute({ config, getAuth: () => fetchAuthQuery(api.auth.api.getUserOrg, {}), getToken, listCollection: async (collection) => { if (collection !== "pages") return []; const entries = await vex.query(api.pages.publishedSlugs, {}); return entries.map(({ slug }) => ({ slug })); },});Four props wire the route to your app:
config— the resolvedVexConfig, so the route can look upaccessand each collection’sroutes.map.getToken— reads the caller’s session token for the current request; a falsy return is treated as “no session” and the route responds401.getAuth— resolves{ user, organization? }for the permission check.listCollection— required for the “Revalidate all” button. Without it, a collection-wide purge request purges nothing and reports an error in the response instead of silently succeeding..map(({ slug }) => ({ slug }))is required here too: the return type isPartial<VexDocument>, andPublishedSlugis an interface with no index signature, so it does not structurally satisfy that without narrowing to the fields the route map actually reads.
Automatic vs. manual purging
Section titled “Automatic vs. manual purging”Every admin-panel write purges its own paths automatically: useVexMutation
(@vexcms/react) POSTs to the revalidate route after its underlying Convex mutation succeeds,
fire-and-forget — a failed purge is a stale page, but a failed save is lost work, so the purge
never delays or fails the mutation the caller is waiting on.
The RevalidateButton (@vexcms/react) covers what an automatic purge can’t reach: a
Convex dashboard edit, npx convex import, a streaming import, or a tab that closed mid-request
— none of those run application code, so nothing purges automatically. It’s user-initiated, so
unlike the automatic purge it renders the request’s pending state and surfaces a failure instead
of swallowing it:
// Purge the document currently open in an edit view<RevalidateButton collection={collection.slug} doc={currentDocument} />
// Purge every path in the collection, from a list view<RevalidateButton collection={collection.slug} />Both call the same route through useVexRevalidate — there is one purge path, two triggers.
Static params, sitemaps, robots, and metadata
Section titled “Static params, sitemaps, robots, and metadata”@vexcms/next/seo turns a Convex publishedSlugs-shaped query into the pieces a public site
needs, without a bespoke reducer per project. Every one of these degrades gracefully rather than
failing the build when Convex is unreachable — CI builds with placeholder env, and a build must
not fail because a live deployment wasn’t available to enumerate.
-
vexStaticParamsbuildsgenerateStaticParamsoutput for a slug-driven route, returning[]— never throwing — when the read fails:export async function generateStaticParams() {const entries = await vexStaticParams({client: vex,getSlug: (item: { slug: string }) => item.slug,paramName: "slug",query: api.pages.publishedSlugs,});return entries.filter((entry) => entry.slug !== "home");} -
createVexSitemapreturnsapp/sitemap.ts’s default export and degrades to a root-only sitemap on a read failure:src/app/sitemap.ts export default createVexSitemap({client: vex,query: api.pages.publishedSlugs,toUrl: (slug) => (slug === "home" ? env.NEXT_PUBLIC_SITE_URL : `${env.NEXT_PUBLIC_SITE_URL}/${slug}`),getSlug: (item) => item.slug,});getUpdatedAtis safe to pass: it is resolved per item, and any entry whose value is missing or non-finite is emitted with no<lastmod>rather than an unparseable one. That matters becauseupdatedAtexists on every content collection but is only populated by a write through the admin panel or API — seeded and imported rows have none. -
createVexRobotsreturnsapp/robots.ts’s default export. It takes no Convex read at all — it’s purely static:src/app/robots.ts export default createVexRobots({siteUrl: env.NEXT_PUBLIC_SITE_URL,disallow: ["/admin", "/api"],}); -
vexMetadataformats aMetadataobject (title, description,metadataBase, canonical link, conditional OG image) but does not fetch anything — resolving and merging page-level and site-level SEO fields is your app’s job, typically in a smallgeneratePageMetadatahelper that callsvex.queryand passes the result through:const metadata = vexMetadata({title,description,siteUrl: env.NEXT_PUBLIC_SITE_URL,path: canonicalPath,imageUrl: ogImageUrl,});Title and description are always set; only
openGraph.imagesdepends onimageUrlresolving. Never gate the wholeopenGraphblock on the image — a page with no image published should still carry an OG title and description.
updatedAt and sitemap lastmod
Section titled “updatedAt and sitemap lastmod”defineCollection auto-injects an optional updatedAt field, stamped on create/update/
publish/unpublish (never on an autosave draft), into every collection that isn’t
meta.protected or an auth-adapter table that already owns the column. Opt a collection out
explicitly with { timestamps: false } when its shape must match something external (an
append-only log, a table an auth adapter dictates).
The field’s type is commitTs() — Convex’s own database-stamped commit timestamp
(ctx.db.vars.commitTs), not a client-computed Date.now(). That only changes how the value
is produced inside @vexcms/core: it’s always resolved to a plain Unix-ms number before it
reaches app code — find/get/search convert Convex’s resolved nanosecond bigint to ms
internally. createVexSitemap.ts and every other consumer still just reads a number, exactly
as before.
This is what a sitemap’s getUpdatedAt (above) reads, and it’s collection-only:
- Globals carry no
updatedAt.defineGlobaldoesn’t inject the field, so a global-backed page (a settings singleton, say) has no per-document timestamp to surface in a sitemap or an SEO<lastmod>. - Auth-adapter tables never receive the injected version.
betterAuthAdapterpopulates its own lockedupdatedAtfrom the auth provider’s real schema attribute where one exists;defineCollectionleaves an already-declared field alone rather than overwriting it.
What’s deliberately out of scope
Section titled “What’s deliberately out of scope”- No server-side dispatch. A Convex-write-triggered purge with no admin panel open at all is deferred — the admin panel already carries a signed-in session, and there’s no design here for authorizing a purge with no request originating from one.
- No CLI or API-key auth mode. A
vex revalidatecommand would need its own credential (a service-account token sitting in a CI/host environment), which is strictly worse than the session-only design this system is built around. /adminand every auth route are deliberately never cached. They’re disallowed inrobots.tsand read throughfetchQuery/dynamic rendering, not the sharedvexclient — caching an authenticated, per-user surface behind a shared CDN entry is the wrong trade in either direction.- A Convex dashboard edit,
npx convex import, or a streaming import never runs application code, so nothing purges automatically for any of them. That’s exactly what the manual Revalidate button and theexport const revalidateISR window exist to cover — the button for “purge now”, the window as a backstop for whichever write nobody caught in time.