Performance & Caching
VeloCMS is built to be measurably faster than WordPress. That is not a marketing claim — it is a set of hard numbers we measure against with Lighthouse. The budgets are defined in the repository's lighthouserc.json and measured on desktop; the current mobile and desktop results are reported on the public /performance page. These budgets are measured, not currently enforced as a blocking CI check. The budgets are: desktop LCP target < 1000ms, INP < 100ms at p75, CLS < 0.05, initial JavaScript < 150KB gzipped, and per-route JS chunks < 50KB gzipped. Treat them as the targets every change is held to.
Performance budgets
| Metric | Budget | Measured by |
|---|---|---|
| LCP (Largest Contentful Paint) | < 1000ms (desktop target) | Lighthouse (budget in lighthouserc.json) |
| INP (Interaction to Next Paint) | < 100ms | Lighthouse (budget in lighthouserc.json) |
| CLS (Cumulative Layout Shift) | < 0.05 | Lighthouse (budget in lighthouserc.json) |
| FCP (First Contentful Paint) | < 800ms | Lighthouse (budget in lighthouserc.json) |
| TBT (Total Blocking Time) | < 200ms | Lighthouse (budget in lighthouserc.json) |
| Speed Index | < 1500ms | Lighthouse (budget in lighthouserc.json) |
| Performance score | >= 0.95 | Lighthouse (budget in lighthouserc.json) |
| JavaScript total | 150KB gzipped | Next.js build output |
| Per-route JS chunk | 50KB gzipped | Next.js build output |
ISR strategy
Public-facing blog pages (/blog/[slug]) use Incremental Static Regeneration. Pages are pre-rendered at build time for all published posts, then regenerated in the background when a revalidation tag fires. The revalidation tag for posts is posts-{tenantId}. Tags for other resources follow the same pattern: settings-{tenantId}, categories-{tenantId}.
import { unstable_cache } from "next/cache";
// Cache post by slug, tag it for selective invalidation
const getPost = unstable_cache(
async (slug: string, tenantId: string) => {
const db = await getTenantDB();
return db.getCollection("posts").getFirstListItem(
`slug = "${slug}" && status = "published"`
);
},
["post-by-slug"],
{
tags: [(tenantId) => `posts-${tenantId}`],
revalidate: 3600, // 1-hour background revalidation fallback
}
);
// In generateStaticParams: pre-render all published posts at build time
export async function generateStaticParams() {
const posts = await getAllPublishedPosts();
return posts.map((p) => ({ slug: p.slug }));
}revalidateTag usage
When an admin publishes, updates, or deletes a post, the server action calls revalidateTag to invalidate the cached pages. In Next.js 16, revalidateTag requires a second argument for the cache profile (P-007). The tag naming convention must match exactly between the cache definition and the revalidation call.
import { revalidateTag } from "next/cache";
export async function publishPostAction(postId: string) {
const session = await getSession();
if (!session) throw new Error("Unauthorized");
// Update post status in PocketBase
const db = await getTenantDB();
await db.getCollection("posts").update(postId, {
status: "published",
published_at: new Date().toISOString(),
});
// Invalidate cached pages for this tenant
// Next.js 16: second arg "default" required (P-007)
revalidateTag(`posts-${session.tenantId}`, "default");
// Audit log (fire-and-forget)
void auditLog({ /* ... */ action: "post.published" });
}next/image always
Every image in VeloCMS uses the Next.js Image component. It handles responsive srcset generation, lazy loading, AVIF/WebP conversion, and intrinsic size calculation to prevent CLS. The plain <img> tag is banned — the ESLint rule @next/next/no-img-element enforces this. For media from Cloudflare R2, the remotePatterns config in next.config.ts allows the R2 public URL domain.
import Image from "next/image";
// CORRECT: explicit width/height prevents CLS
<Image
src={post.featured_image_url}
alt={post.featured_image_alt ?? post.title}
width={1200}
height={630}
priority={isAboveFold} // priority=true for LCP candidate images
sizes="(max-width: 768px) 100vw, 1200px"
/>
// WRONG: raw <img> — banned by ESLint, causes CLS, no srcset
<img src={post.featured_image_url} alt={post.title} />Server Components default
Next.js App Router defaults to Server Components, which ship zero client-side JavaScript for their subtree. VeloCMS follows this default — add 'use client' only when a component needs state, effects, or browser APIs. Every unnecessary 'use client' directive adds its module graph to the JS bundle, quickly consuming the 150KB budget.
| Component type | JS shipped to client | Use when |
|---|---|---|
| Server Component (default) | 0 bytes | Static content, data fetching, SEO |
| Client Component ('use client') | Full module graph | useState, useEffect, browser APIs, event handlers |
| Mixed: SC shell + CC leaf | Only CC subtree | Most admin UI — wrap only interactive parts in 'use client' |
Dynamic imports for non-critical components
Heavy components that are not needed on first paint use Next.js dynamic imports. The ssr: true option keeps the component in the initial HTML for SSR (important for LCP), while the JavaScript loads after the page is interactive. This pattern is used for the TipTap rich text editor, the page builder drag-and-drop UI, and data visualization components.
import dynamic from "next/dynamic";
// ssr: true — component renders in initial HTML, JS loads after
// This keeps LCP fast for above-fold content while deferring heavy JS
const RichTextEditor = dynamic(
() => import("@/components/editor/rich-text-editor"),
{ ssr: true }
);
const PageBuilder = dynamic(
() => import("@/components/page-builder/canvas"),
{
ssr: false, // Page builder is admin-only, SSR not needed
loading: () => <div className="animate-pulse h-64 bg-muted rounded" />,
}
);SSR visibility rule
Components using IntersectionObserver (ScrollReveal, Counter, animated reveals) must render content-visible on SSR and first paint. If content starts with opacity-0 and becomes visible only after the observer fires, Lighthouse, Playwright screenshots, and AI-SEO crawlers see blank content — LCP registers as 0. The pattern is: initialize a mounted state, show content before mount or after observer fires.
"use client";
import { useState, useEffect, useRef } from "react";
export function ScrollReveal({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
const [visible, setVisible] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
setMounted(true);
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) setVisible(true);
});
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);
// Content is visible: (a) before mount (SSR/first paint) or (b) after observer fires
const showContent = !mounted || visible;
return (
<div ref={ref} className={showContent ? "opacity-100 transition-opacity" : "opacity-0"}>
{children}
</div>
);
}Bundle analysis
Every production build of the VeloCMS app prints a route-by-route breakdown of JS chunk sizes, and the VeloCMS team reviews that output whenever a change adds a dependency or a 'use client' boundary. A bundle treemap is used to find which packages are bloating a specific route. This is the VeloCMS team's own release process, not something you run for your site. Your blog gets the result: pages are built from the same budgeted code.
Patterns that break the budget
- import * as _ from 'lodash' — imports the full 70KB library; use specific imports: import { debounce } from 'lodash-es'
- Inline base64 images larger than 10KB — use next/image with external URL instead
- Using <img> instead of <Image> — no responsive srcset, no lazy load, causes CLS
- Adding 'use client' to components that only render static HTML — adds full module graph to bundle
- Blocking <script> tags in <head> — use next/script with strategy='afterInteractive'
- Importing heavy charting or 3D libraries at route level — wrap in dynamic() with ssr: false
- Animations that start opacity-0 without a mounted guard — invisible to Lighthouse and crawlers
Reference
| Tool | Purpose |
|---|---|
| Lighthouse CI | Measures production URLs against the budgets in lighthouserc.json; results are published on /performance |
| next/image | Responsive images with lazy loading and explicit dimensions |
| Bundle analyzer | Treemap used by the VeloCMS team to find heavy dependencies per route |
| /performance | Public page that reports current mobile and desktop results |