Performance & Caching

10 min readUpdated 27 Apr 2026

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

MetricBudgetEnforcement
LCP (Largest Contentful Paint)< 1000msLighthouse CI, ERROR level
INP (Interaction to Next Paint)< 100msLighthouse CI, ERROR level
CLS (Cumulative Layout Shift)< 0.05Lighthouse CI, ERROR level
FCP (First Contentful Paint)< 800msLighthouse CI, ERROR level
TBT (Total Blocking Time)< 200msLighthouse CI, ERROR level
Speed Index< 1500msLighthouse CI, ERROR level
Performance score>= 0.95Lighthouse CI, ERROR level
JavaScript total150KB gzippedNext.js build output + CI
Per-route JS chunk50KB gzippedNext.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}.

Blog post page caching (src/app/(blog)/blog/[slug]/page.tsx)
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 }));
}
typescript

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.

src/lib/actions/post.ts (revalidation after publish)
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" });
}
typescript

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.

next/image usage pattern
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} />
typescript

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 typeJS shipped to clientUse when
Server Component (default)0 bytesStatic content, data fetching, SEO
Client Component ('use client')Full module graphuseState, useEffect, browser APIs, event handlers
Mixed: SC shell + CC leafOnly CC subtreeMost 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.

Dynamic import pattern
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" />,
  }
);
typescript

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.

SSR-safe animation pattern
"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>
  );
}
typescript

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.

Bundle analysis commands
# 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 browser
bash

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

File / ToolPurpose
lighthouserc.jsonBudget thresholds and CI configuration
.github/workflows/lighthouse.ymlLighthouse CI workflow (daily + on PR)
next.config.tsnext/image remotePatterns, bundle analyzer config
.claude/rules/performance-budget.mdFull performance budget rules for contributors
npm run lighthouseLocal Lighthouse run against production URLs