Most performance guides are written for huge apps with dedicated platform teams. But a lot of us spend our days building sites for plumbers, dentists, salons and law firms. Those sites are small, yet speed matters even more for them, because a visitor on a phone usually wants one thing: call, book, or get directions. If the page stalls, they hit back and tap the next result.

This post is the checklist we use at CodeHunterz when we build or rescue a Next.js site for a local business. It focuses on the three Core Web Vitals Google documents on web.dev: Largest Contentful Paint (LCP), Interaction to Next Paint (INP) and Cumulative Layout Shift (CLS). Google's published "good" thresholds are an LCP of 2.5 seconds or less, an INP of 200 milliseconds or less, and a CLS of 0.1 or less, measured at the 75th percentile of page loads.

Let's go through what actually moves those numbers.

1. Measure before you touch anything

Lab tools are great for debugging, but field data is what Google uses. Start with PageSpeed Insights, which shows both. Then add real user monitoring to your own app so you are not guessing.

Next.js ships a hook for this:

// app/web-vitals.tsx 'use client' import { useReportWebVitals } from 'next/web-vitals' export function WebVitals() { useReportWebVitals((metric) => { const body = JSON.stringify({ name: metric.name, value: metric.value, rating: metric.rating, page: window.location.pathname, }) // sendBeacon survives page unloads, fetch with keepalive is the fallback if (navigator.sendBeacon) { navigator.sendBeacon('/api/vitals', body) } else { fetch('/api/vitals', { body, method: 'POST', keepalive: true }) } }) return null } 

Render <WebVitals /> once in your root layout and log the results somewhere you can query. After a week you will know which template is actually slow on real phones, which is often not the one you expected.

2. LCP: make the hero show up fast

On small-business sites the LCP element is almost always the hero image or the main headline. Common culprits:

  • A huge, uncompressed hero photo straight from the client's camera.
  • The hero image lazy loaded by default.
  • A web font that blocks the headline from painting.
  • A slow server response because every page is rendered on demand.

Use next/image with priority on the hero

import Image from 'next/image' export function Hero() { return ( <section className="relative h-[70vh]"> <Image src="/images/hero-team-on-site.jpg" alt="Technician repairing an AC unit" fill priority sizes="100vw" className="object-cover" /> <h1 className="relative z-10">Same-day AC repair</h1> </section> ) } 

priority tells Next.js to preload the image and skip lazy loading. Only use it for the image above the fold. Putting it on everything cancels the benefit.

The sizes prop matters too. Without it, the browser may download a far larger file than the layout needs on a phone.

Load fonts without blocking text

// app/layout.tsx import { Poppins } from 'next/font/google' const poppins = Poppins({ subsets: ['latin'], weight: ['400', '600'], display: 'swap', }) export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en" className={poppins.className}> <body>{children}</body> </html> ) } 

next/font self-hosts the font files at build time, so there is no extra connection to a third-party font server. Keep the number of weights small. Every weight is another file.

Prefer static rendering for pages that rarely change

A service page for "Water heater repair" does not change every request. Render it statically and revalidate occasionally:

// app/services/[slug]/page.tsx export const revalidate = 86400 // regenerate at most once a day export async function generateStaticParams() { const services = await getServices() return services.map((s) => ({ slug: s.slug })) } 

Static HTML from a CDN edge is about as fast a server response as you can get, and it helps both LCP and Time to First Byte.

3. INP: keep the main thread free

INP replaced First Input Delay as a Core Web Vital in 2024, and Google explains the metric in detail in its INP guide. It measures how quickly the page responds visually after a tap or click. On local business sites the usual problems are:

  • Chat widgets, booking widgets and tracking scripts all loading on page load.
  • Large client components that hydrate the whole page when only a menu is interactive.
  • Click handlers that do heavy work synchronously.

Defer third-party scripts

import Script from 'next/script' export function ThirdParty() { return ( <> <Script src="https://example-analytics.com/tag.js" strategy="afterInteractive" /> <Script src="https://example-chat.com/widget.js" strategy="lazyOnload" /> </> ) } 

lazyOnload waits until the browser is idle. A chat bubble does not need to exist in the first second.

Keep client components small

With the App Router, components are server components by default. Only the interactive pieces need 'use client'. A common mistake is putting 'use client' at the top of a whole page because one button needs state. Split it:

// app/contact/page.tsx (server component) import { CallButton } from './call-button' export default function ContactPage() { return ( <main> <h1>Talk to a licensed plumber today</h1> <p>Open 7 days a week for emergency calls.</p> <CallButton phone="+15555550100" /> </main> ) } 
// app/contact/call-button.tsx 'use client' export function CallButton({ phone }: { phone: string }) { return ( <a href={`tel:${phone}`} onClick={() => window.dispatchEvent(new CustomEvent('call_click'))} className="btn-primary" > Call now </a> ) } 

Less JavaScript shipped means less hydration work and faster responses to taps.

Yield during heavy handlers

If a handler has to do real work, like filtering a long list of locations, let the browser paint first:

async function onFilterChange(value: string) { setLoading(true) // visual feedback first await new Promise((r) => setTimeout(r, 0)) // yield to the browser setResults(expensiveFilter(allLocations, value)) setLoading(false) } 

React's useTransition is another clean option for marking updates as non-urgent.

4. CLS: stop the page from jumping

Layout shift is the one users notice most. They go to tap "Call now" and the button moves because a banner loaded above it.

Fixes that cover most cases:

  • Always give images and embeds dimensions. next/image requires width and height or fill inside a sized container, which helps.
  • Reserve space for cookie banners and promo bars instead of injecting them at the top of the page.
  • Use display: 'swap' with next/font, which also applies size-adjusted fallback fonts to reduce the shift when the real font arrives.
  • Give embedded maps and review widgets a fixed aspect ratio.
.map-embed { aspect-ratio: 16 / 9; width: 100%; } 

5. Do not forget structured data

Performance gets you a fast page. Structured data helps search engines understand what that page is about. For local businesses, Google documents a LocalBusiness structured data type. In the App Router you can add JSON-LD directly in a server component:

export default function Page() { const jsonLd = { '@context': 'https://schema.org', '@type': 'Plumber', name: 'Example Plumbing Co', url: 'https://example.com', telephone: '+1-555-555-0100', areaServed: 'Austin, TX', openingHours: 'Mo-Su 07:00-20:00', } return ( <> <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> {/* page content */} </> ) } 

Validate the output with Google's Rich Results Test before shipping. Only mark up information that is actually visible and true on the page.

6. A quick pre-launch checklist

Before handing a small-business site to a client, we run through this list:

  1. Hero image uses next/image with priority and a sensible sizes value.
  2. Fonts load through next/font with two or three weights at most.
  3. Service and location pages are statically generated.
  4. Third-party scripts use afterInteractive or lazyOnload.
  5. 'use client' only appears on genuinely interactive components.
  6. Every image, map and embed has reserved space.
  7. The phone number is a real tel: link that is visible without scrolling on mobile.
  8. LocalBusiness JSON-LD validates without errors.
  9. Web vitals reporting is live so you can watch field data after launch.

None of these steps are exotic. The hard part is doing all of them consistently on every page, including the ones built late on a Friday.

Wrapping up

For a local business, the website is often the first and only impression before a customer decides to call. A fast, stable page is not a vanity metric there. It is the difference between a ringing phone and a bounce.

If you want to check a site quickly, we keep a set of free website and SEO tools on our site. And if you are planning a new build, our Next.js development service page explains how we approach performance-first projects for small businesses.

Thanks for reading. Questions or your own Core Web Vitals tricks are welcome in the comments.

CodeHunterz Team
codehunterz.com