When Claude Code loads it
Use when building, reviewing or optimizing any website, landing page, marketing site, docs site or Next.js/React app; when adding a client component, animation library, hero image, font, analytics/chat script, video embed, canvas or shader; when a page feels slow, janky or shifts; or when `void perf`, `void smooth`, Lighthouse or PageSpeed report problems. Targets Next.js 16 App Router, React 19 and Tailwind 4, with portable guidance for Astro and other SSR stacks.
Targets are for a mid-range phone on Slow 4G (Lighthouse mobile: 150 ms RTT, 1.6 Mbps, 4x CPU).
Verify every claim with void perf and void smooth against a production build (see the audit skill).
The 10 rules that matter most
- Server-render by default.
'use client'only on leaf components that need state, effects or event handlers. Never onpage.tsxorlayout.tsx. - The LCP element is in the initial HTML and visible at first paint. No
loading="lazy", noopacity:0entrance, no CSSbackground-image, nowaiting for data around it. - One high-priority image per route:
fetchPriority="high"+loading="eager"(orpreload) onnext/image.priorityis deprecated in Next 16. Never use it. - Every
,,,haswidth+heightoraspect-ratio. Late UI (banners, toasts, consent) isposition: fixedor has reservedmin-height. - Marketing routes: ≤ 170 KB gzip first-load JS (Next's own baseline is ~133 KB, so you have ~40 KB). App routes ≤ 300 KB.
- Prefer CSS over JS for motion. Animate only
transformandopacity. If you need Motion, useLazyMotion+m(+32 KB), never the fullmotioncomponent (+46 KB). - Fonts: ≤ 2 families, ≤ 4 files,
next/font, variable WOFF2,subsets: ['latin']. No Google Fonts, no icon fonts. - Third-party scripts load with
next/script strategy="lazyOnload". Chat widgets and video embeds get a static facade that loads the real thing on click. - No scroll listeners for effects. Use
IntersectionObserveror CSS scroll-driven animations. Touch/wheel listeners are{ passive: true }. No Lenis or other smooth-scroll libraries. - Every loop stops when unseen. rAF, intervals and infinite CSS animations pause offscreen (
IntersectionObserver) and when the tab is hidden (visibilitychange). At most one WebGL context per page.
Budgets
"Gate" = the void perf default (kind: "marketing" or "app" in void.config.ts); exceeding it fails the run. "Target" = what a polished marketing page should reach.
| Metric (mobile lab, median of runs) | Marketing gate | Marketing target | App gate | Rule id |
|---|---|---|---|---|
| LCP | ≤ 2.5 s (desktop ≤ 1.5 s) | desktop ≤ 1.2 s | ≤ 2.5 s | perf/lcp-slow |
| TBT (lab proxy for INP) | ≤ 200 ms | ≤ 100 ms | ≤ 300 ms | perf/tbt-high |
| INP (scripted interactions) | ≤ 200 ms | ≤ 100 ms desktop (unthrottled) | ≤ 200 ms | smooth/inp-slow |
| CLS | ≤ 0.1 | ≤ 0.05 after full scroll | ≤ 0.1 | perf/cls-high, smooth/layout-shift-on-scroll |
| TTFB | ≤ 800 ms | ≤ 600 ms | ≤ 800 ms | perf/ttfb-slow |
| JS transfer at load | ≤ 170 KB | ≤ 40 KB above the framework | ≤ 300 KB | perf/js-budget |
| CSS transfer | ≤ 25 KB | — | ≤ 40 KB | perf/css-budget |
| Fonts | ≤ 120 KB, ≤ 2 families, ≤ 4 files | ≤ 2 files preloaded | same | perf/font-budget, perf/too-many-fonts |
| Images at load (total) | ≤ 1,000 KB | LCP image ≤ 150 KB | — | perf/image-budget |
| HTML document | ≤ 30 KB | — | ≤ 60 KB | — |
| Dropped frames while scrolling | ≤ 5% | 0 ms long-frame blocking | ≤ 5% | smooth/scroll-jank, smooth/long-frames-during-scroll |
| DOM | ≤ 1,400 elements, depth ≤ 32 | — | same | perf/dom-size |
Lighthouse performance (--lighthouse) |
≥ 90 | — | ≥ 80 | perf/lighthouse-score-low |
Next's own framework baseline is ~133 KB gzip, so a marketing page has ~40 KB for its own client code. Astro/static pages with no framework runtime: ≤ 50 KB JS (islands only). Override budgets in void.config.ts (see the audit skill).
Before adding any client JS, ask in order
- Can HTML/CSS do it?
,,popover,:has(),@starting-style, CSS transitions, scroll-snap. Then write zero JS. - Can the server do it? Markdown, syntax highlighting (Shiki at build time), charts as static SVG, date formatting, icons (lucide in a Server Component costs ~1 KB). Then do it in a Server Component.
- Is it needed at first paint? No:
next/dynamic(inside a client component) mounted on interaction or visibility. - Is it a third party?
lazyOnloador a facade. Never synchronous in. - Does it add > 5 KB gzip? Check the size (bundlephobia,
next experimental-analyze) and find a lighter option or cut it. Known costs:three140–160 KB,swiper~40 KB,react-markdown~50 KB, Prism/react-syntax-highlighter~250 KB,lenis~10 KB plus a permanent rAF loop.
Banned in 'use client' modules on marketing routes: prismjs, highlight.js, shiki, react-markdown, remark*, rehype*, moment, chart.js, recharts, d3, framer-motion, and the motion component (import { motion } from "motion/react"; use m from motion/react-m instead).
Server-first component boundaries
- Push
'use client'down to the smallest interactive leaf (button, menu, carousel). Pass Server Components into client wrappers aschildren. - A Server Component imported from a
'use client'file gets bundled as client code. Import it from a server parent and pass it down. - Pass only the props the client needs. Whole CMS documents or ASTs get serialized twice: once into the HTML, once into the RSC payload.
- Put slow data behind
with a skeleton of the same height. Keep the hero and h1 outside it. - Pages that can be static stay static (
○innext build). Use'use cache'withcacheComponents: truefor cached dynamic data. - Details and code:
references/next-performance.md. Hook and effect bugs:references/react-pitfalls.md.
LCP recipe
- Know the LCP element. On marketing pages it is the hero image or the h1. A typographic hero is cheaper (it only needs HTML, CSS and the font).
- Serve it from static or prerendered HTML behind a CDN. TTFB is about 40% of LCP.
- Image hero: a static import plus
fill sizes="100vw" fetchPriority="high" loading="eager", AVIF/WebP, ≤ 150 KB on mobile. Every other image stays lazy with an accuratesizes. - Don't hide it. An element at
opacity:0is not an LCP candidate until it becomes visible. Animate secondary elements, or start the hero from a visible state. - No render-blocking third parties before it. With
next/fontandnext/imageeverything is same-origin, so you need zeropreconnects.
Recipes: references/images-fonts.md. Ids: perf/lcp-image-lazy, perf/lcp-image-no-priority, perf/lcp-background-image, perf/image-oversized, perf/image-legacy-format, perf/render-blocking.
INP recipe (every click/tap/key → next frame ≤ 200 ms)
- Ship less JS (above). Hydration cost scales with the size of the client component tree.
- Handlers: do the visual acknowledgement synchronously (toggle a class or pending state), then
await yieldToMain(), then do the heavy work, analytics or network. - Use
startTransitionfor non-urgent state updates (filters, tab content). UseuseDeferredValuefor expensive renders driven by typing. - No long task > 50 ms after any interaction. Split loops with
scheduler.yield()(Safari needs thesetTimeoutfallback). - Per-frame values (pointer position, scroll progress) never go through React state. Write them to the DOM, a CSS variable on the element itself, or a MotionValue.
- Consider
reactCompiler: trueinnext.config.tsfor app routes with heavy re-renders.
export const yieldToMain = () =>
(globalThis as any).scheduler?.yield ? (globalThis as any).scheduler.yield() : new Promise<void>((r) => setTimeout(r, 0));CLS recipe
- Media:
width/heightattributes oraspect-ratio.next/imagewithfillneeds a sized, positioned parent. Id:perf/images-missing-dimensions. - Fonts:
next/fontkeepsadjustFontFallbackon. Outside Next, add asize-adjustfallback@font-face.font-display: swaporoptional, neverblock. - Late content: announcement bars are server-rendered; cookie banners and toasts are
fixed; ad and embed slots havemin-height; skeletons match the final height within ±8 px. - Never animate
width,height,top,left,marginorpadding. For accordions usegrid-template-rows: 0fr → 1fr. scrollbar-gutter: stableonhtmlso modals and scroll locks don't shift the page.
Images, fonts, third parties (summary)
next.config.ts:images: { formats: ['image/avif', 'image/webp'] }. Next 16 only allowsquality75 unless you add values toimages.qualities.- Below-the-fold: default lazy +
sizes(e.g.sizes="(min-width: 1024px) 33vw, 100vw"). Decorative images:alt="". - Video:
poster,preload="none", no autoplay with sound. YouTube/Vimeo: a thumbnail facade withaspect-ratio: 16/9that swaps in the iframe on click. - Scripts:
beforeInteractiveonly for consent or bot detection,afterInteractiveonly for analytics you truly need at load,lazyOnloadfor everything else.strategy="worker"does not work in the App Router. - Fonts: turn
adjustFontFallbackoff for monospacenext/font/local(it scales Arial to ~131% and code renders oversized until the swap). Usepreload: falseon decorative or italic faces. Scope display fonts to the route layout that uses them.
Animation and smoothness
Timing, easing and choreography live in the motion skill. The performance rules:
- Only
transformandopacity.filter/clip-pathon small elements only. Nobackdrop-filterover large or scrolling areas (at most one blurred header). Notransition: all. - Set
will-changeonly while an animation runs, never globally, and on at most 3 elements. - Scroll effects: CSS
animation-timeline: view()inside@supports (animation-timeline: view()), because Firefox lacks it and the page must look complete without it. Or useIntersectionObserver. At most one scroll-scrubbed set piece per page. - Long pages (> 3 viewports):
content-visibility: auto; contain-intrinsic-size: auto 800pxon repeated below-fold sections. Never on the first viewport. - Every animation respects
prefers-reduced-motion: reduceand falls back to a finished static frame. Content is never hidden. - Details:
references/rendering-smoothness.md. Ids:smooth/animate-layout-property,smooth/transition-all,smooth/will-change-overuse,smooth/reduced-motion-ignored,smooth/scroll-listener-nonpassive(list all:void rules smooth).
WebGL / canvas: allowed only if all of these hold
Allowed for a hero or ambient backdrop on marketing pages. In app routes only when the canvas is the product.
- One live context per page (two at most). Never inside
.map()or a list item: browsers cap contexts at ~8–16 and silently kill the oldest. For many instances, draw one static frame or use CSS gradients. - Pause, don't skip. Cancel the rAF when offscreen (
IntersectionObserver) or whendocument.hidden. Never pause because the user is idle. - Cap DPR at 1.5 (1 for full-screen backdrops) and fps at 30 for ambient effects. Size backdrops to the viewport (
position: fixed), not the page. - Reduced motion: render one static frame and stop.
- Cleanup: cancel the rAF, remove listeners,
getExtension('WEBGL_lose_context')?.loseContext()on unmount, handlewebglcontextlost. Update uniforms when props change; don't rebuild the program. - Load after paint:
next/dynamic(..., { ssr: false })from a client component, a reserved box with the shader's average color as placeholder,aria-hidden+pointer-events-nonewhen decorative. - Prefer
ogl(10–14 KB) or raw WebGL2 overthree(140 KB+) for full-screen quads. Mount persistent backdrops once in the root layout so they don't re-init on every navigation.
Paste-ready hook: references/rendering-smoothness.md §8. Ids: smooth/multiple-webgl-contexts, smooth/raf-loop-idle, lint/webgl-in-map, lint/raf-without-cancel.
bfcache and caching
- No
unloadlisteners. Usepagehide. Addbeforeunloadonly while there are unsaved changes. Id:perf/bfcache-blocked. - Hashed assets (
/_next/static/*):public, max-age=31536000, immutable(Next does this). HTML:no-cacheor CDNs-maxage+stale-while-revalidate. Neverimmutableon HTML. Id:perf/cache-headers. - Compression:
next startonly gzips. Put brotli/zstd at the CDN or proxy (and setcompress: falsewhen the proxy compresses). Id:perf/no-text-compression. output: 'standalone'doesn't copypublic/or.next/static. Copy them, or fonts and images 404.- Next App Router: rely on
prefetch. Don't add document-wide Speculation Rulesprerender. Multi-page apps (Astro):prefetchatmoderate.
Never do
'use client'at the top of a page or layout.priorityonnext/image(Next 16) ·loading="lazy"on the hero · a hero as CSSbackground-image.- An h1 or hero at
initial={{ opacity: 0 }}waiting for hydration. - Google Fonts
/@import· icon fonts · more than 2 families. framer-motion, ormotion.divwithoutLazyMotion· Lenis, locomotive-scroll, GSAP ScrollTrigger pinning.- Animating
width/height/top/left/margin/box-shadow·transition: all· permanentwill-change. setStateonscroll/mousemove/rAF ·setIntervalor subscriptions in the render body ·window/document/navigatorat module scope or during render.- Non-passive
wheel/touchstartlisteners onwindow/document. - A WebGL context per card · rAF that keeps running offscreen · canvas at DPR 2+.
- Synchronous third-party scripts in
· autoplaying background video · chat widgets that load at startup. unloadhandlers ·Cache-Control: immutableon HTML.- Client-side fetches of rate-limited public APIs (e.g. GitHub stars) on every visit. Fetch on the server with
revalidate. - Measuring against
next dev.
Verify
- After each significant edit:
void lint(seconds). It catcheslint/image-priority-deprecated,lint/heavy-import,lint/page-level-use-client,lint/animate-layout-prop,lint/scroll-listener-nonpassive,lint/no-reduced-motion,lint/timer-in-render,lint/browser-global-in-render,lint/raf-without-cancel,lint/webgl-in-map,lint/unload-listener,lint/google-fonts-link,lint/script-strategy. - Before declaring done:
next build, thenvoid perf --start "next start -p 3000" --port 3000andvoid smooth --start "next start -p 3000" --port 3000(scroll jank, INP of interactions, non-composited animations, idle rAF loops, WebGL context count). - Fix in order: errors → budgets → warnings. Look up any id with
void rules. The full loop is in theauditskill.