Performance & Caching
VeloCMS is built to be measurably faster than WordPress. That is not a marketing claim — it is a set of hard numbers enforced in CI via Lighthouse. The budgets are: LCP < 1000ms, INP < 100ms at p75, CLS < 0.05, initial JavaScript < 150KB gzipped, and per-route JS chunks < 50KB gzipped. Violating any of these in a PR triggers a failing CI check.
Performance budgets
| Metric | Budget | Enforcement |
|---|---|---|
| LCP (Largest Contentful Paint) | < 1000ms | Lighthouse CI, ERROR level |
| INP (Interaction to Next Paint) | < 100ms | Lighthouse CI, ERROR level |
| CLS (Cumulative Layout Shift) | < 0.05 | Lighthouse CI, ERROR level |
| FCP (First Contentful Paint) | < 800ms | Lighthouse CI, ERROR level |
| TBT (Total Blocking Time) | < 200ms | Lighthouse CI, ERROR level |
| Speed Index | < 1500ms | Lighthouse CI, ERROR level |
| Performance score | >= 0.95 | Lighthouse CI, ERROR level |
| JavaScript total | 150KB gzipped | Next.js build output + CI |
| Per-route JS chunk | 50KB gzipped | Next.js build output + CI |
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
After npm run build, Next.js prints a route-by-route breakdown of JS chunk sizes. Review this output before merging any PR that adds new dependencies or 'use client' boundaries. The NEXT_ANALYZE=true environment variable enables the @next/bundle-analyzer visual treemap, useful for identifying which packages are bloating a specific route.
# Standard build — prints route JS sizes
npm run build
# Visual treemap (requires NEXT_ANALYZE env var support in next.config.ts)
NEXT_ANALYZE=true npm run build
# Run Lighthouse against production URLs (3-run median)
npm run lighthouse
# Check a specific route's chunks
cat .next/analyze/client.html # opens in browserPatterns 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
| File / Tool | Purpose |
|---|---|
| lighthouserc.json | Budget thresholds and CI configuration |
| .github/workflows/lighthouse.yml | Lighthouse CI workflow (daily + on PR) |
| next.config.ts | next/image remotePatterns, bundle analyzer config |
| .claude/rules/performance-budget.md | Full performance budget rules for contributors |
| npm run lighthouse | Local Lighthouse run against production URLs |