Stop over-fetching your headless CMS
How we use select and depth in Payload CMS queries to reduce response times and improve TTFB across listing pages and sitemaps.
The Blog
Not everything belongs in the initial JavaScript bundle. Forms, custom cursors, and heavy interactive components get dynamically imported so they don't block the content users came to see.
The problem
A Next.js application bundles JavaScript for every component a page imports. If the contact page imports a form component with validation, reCAPTCHA, and HubSpot integration, that JavaScript loads even before the visitor scrolls to the form. If the layout imports a custom cursor with motion calculations and easing, that code ships on every page, including pages where the cursor isn't the reason anyone visited.
The cost isn't just file size. It's parse time, execution time, and the delay before the page becomes interactive. On mobile devices with slower processors and network conditions, the difference between a lean initial bundle and a bloated one is measurable in Core Web Vitals, particularly INP (Interaction to Next Paint) and LCP.
The goal isn't to eliminate JavaScript. It's to load the right JavaScript at the right time.
The pattern
Next.js provides dynamic() from next/dynamic for code-splitting at the component level. A dynamically imported component isn't included in the initial bundle. Instead, the code is fetched when the component is about to render.
We use this for two categories of component: heavy interactive elements that appear below the fold, and client-only components that have no meaningful server-rendered state.
Forms are the clearest example. Our enquiry form includes client-side validation, reCAPTCHA v3 verification, and a server action that writes to both Payload CMS and HubSpot. None of that JavaScript needs to load before the visitor scrolls to the form. The form block uses dynamic(() => import('@blocks/Form/index')), the form component loads when it enters the viewport, not when the page loads.
The custom cursor is the other case. It's a client-only component, mouse tracking, easing calculations, and motion values that have no server-rendered output. We load it with `dynamic(..., { ssr: false })` in the providers layer. It hydrates after the page is interactive, adding polish without blocking content.
The balance
Code-splitting introduces its own cost: a network request when the component loads. If every component on the page is dynamically imported, the visitor sees a cascade of loading states instead of a coherent page. The initial bundle is small, but the experience feels slow because components pop in one at a time.
The rule we follow: split components that are heavy, below the fold, or client-only. Don't split components that form the visible page structure, headers, hero sections, content blocks that appear above the fold.
Navigation, layout shells, and above-the-fold content blocks stay in the main bundle. They're what the visitor sees first, and any delay in rendering them defeats the purpose of performance optimisation.
The balance is keeping the critical path lean without fragmenting the page into a dozen lazy-loaded pieces that each trigger their own request.
The balance
The outcome is straightforward. Pages load with the content and structure visitors came to see. Interactive components, forms, cursors, heavy animations, arrive when they're needed, not before.
For Core Web Vitals, this means a faster LCP (less JavaScript competing with content rendering) and a better INP (less code to parse before the page responds to input). The total JavaScript is the same, it's just distributed across time rather than loaded upfront.
For editors, nothing changes. They add form blocks and interactive sections through Payload CMS the same way they always have. The code-splitting is an implementation detail that lives in the component layer, invisible to content workflows.