Last month, our agency website scored 55 on Google PageSpeed Insights (mobile). Today, it scores 94 on desktop and 78 on mobile — and we're still pushing higher.
Here are the 7 changes that made the biggest difference, with actual code you can copy into your Next.js project today.
1. Lazy Load Third-Party Scripts (GTM, Analytics, Chat Widgets)
This single change gave us the biggest performance jump. Google Tag Manager alone was loading 250KB of JavaScript on page load — most of it unused.
Before: GTM loads immediately, blocking the main thread.
// layout.tsx — DON'T do this
import { GoogleTagManager, GoogleAnalytics } from '@next/third-parties/google'
export default function Layout({ children }) {
return (
<html>
<GoogleTagManager gtmId="GTM-XXXXX" />
<body>{children}</body>
<GoogleAnalytics gaId="G-XXXXX" />
</html>
)
}
After: GTM loads 3 seconds after page load or on first user interaction — whichever comes first.
// components/Analytics.tsx
'use client'
import { useEffect, useState } from 'react'
import { GoogleTagManager, GoogleAnalytics } from '@next/third-parties/google'
export default function Analytics() {
const [load, setLoad] = useState(false)
useEffect(() => {
const timer = setTimeout(() => setLoad(true), 3000)
const onInteract = () => setLoad(true)
window.addEventListener('scroll', onInteract, { once: true })
window.addEventListener('click', onInteract, { once: true })
return () => {
clearTimeout(timer)
window.removeEventListener('scroll', onInteract)
window.removeEventListener('click', onInteract)
}
}, [])
if (!load) return null
return (
<>
<GoogleTagManager gtmId={process.env.NEXT_PUBLIC_GTM_ID!} />
<GoogleAnalytics gaId={process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID!} />
</>
)
}
Impact: Reduced unused JavaScript by ~130KB. LCP improved by 800ms.
We did the same for our Tawk.to chat widget — deferring it by 5 seconds. Users don't need live chat in the first 5 seconds anyway.
2. Fix the Hidden LCP Killer: CSS Animations
Our hero section had a text reveal animation — each word faded in with a blur effect. Looked great. But Google's Lighthouse saw this:
// The LCP element starts invisible!
<span style={{ opacity: 0.08, filter: 'blur(4px)' }}>
Our hero text here
</span>
Lighthouse measures LCP at the point when the largest content becomes visible. An element with opacity: 0.08 is essentially invisible — so our LCP was 5.6 seconds (waiting for the animation to complete).
Fix: Make the LCP element visible immediately. Animate everything else.
// Hero description (LCP element) — NO animation
<p className="hero-description">
We build modern, fast websites...
</p>
// H1, buttons, decorative elements — animate freely
<motion.h1 initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
Modern Web Development
</motion.h1>
Impact: LCP dropped from 5.6s to 1.3s on desktop.
3. Dynamic Imports for Below-the-Fold Content
Everything the user can't see on first load doesn't need to be in the initial bundle.
import dynamic from 'next/dynamic'
// These sections are below the fold — lazy load them
const ProjectsSection = dynamic(() => import('@/components/ProjectsSection'))
const FAQSection = dynamic(() => import('@/components/FAQSection'))
const TestimonialsSection = dynamic(() => import('@/components/TestimonialsSection'))
const TechStackSection = dynamic(() => import('@/components/TechStackSection'))
// CustomCursor only makes sense on desktop
const CustomCursor = dynamic(() => import('@/components/CustomCursor'), {
ssr: false,
loading: () => null
})
Impact: Initial JS bundle reduced by ~45KB.
4. Image Cache TTL: From 60 Seconds to 30 Days
This was an embarrassing oversight. Next.js defaults minimumCacheTTL to 60 seconds for optimized images. Every minute, returning visitors were re-downloading the same images.
// next.config.mjs
const nextConfig = {
images: {
minimumCacheTTL: 2592000, // 30 days
formats: ['image/webp', 'image/avif'],
},
}
Also added proper cache headers for all static assets:
async headers() {
return [
{
source: '/:all*(svg|jpg|jpeg|png|gif|ico|webp|woff|woff2)',
headers: [
{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }
],
},
{
source: '/_next/static/:path*',
headers: [
{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }
],
},
]
}
Impact: Repeat visit load time dropped dramatically. PageSpeed "Serve static assets with efficient cache policy" warning disappeared.
5. Preconnect to External Domains
The browser spends time establishing connections to external domains (DNS lookup + TCP handshake + TLS). Tell it to start early:
// layout.tsx — in the <head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />
Impact: FCP improved by ~100ms. Small but free.
6. ISR + On-Demand Revalidation Instead of SSR
We moved from force-dynamic (SSR every request) to ISR with on-demand revalidation. Pages are pre-rendered and served from cache. When content changes in the CMS, a webhook triggers revalidation of only the affected pages.
// Any page.tsx
export const revalidate = 3600 // Serve from cache, refresh hourly
// api/revalidate/route.ts — called by CMS webhook
import { revalidatePath } from 'next/cache'
export async function POST(request: NextRequest) {
const { collection, slug } = await request.json()
if (collection === 'blogs' && slug) {
revalidatePath(`/blogs/${slug}`)
revalidatePath('/blogs')
}
revalidatePath('/')
return NextResponse.json({ revalidated: true })
}
Impact: TTFB dropped from ~400ms (SSR) to ~50ms (cached). Pages feel instant.
7. Target Modern Browsers Only
By default, Next.js transpiles code for older browsers, adding polyfills you probably don't need. Array.prototype.flat, Object.fromEntries, String.prototype.trimEnd — all natively supported in every browser your users actually use.
// package.json
{
"browserslist": [
"last 2 Chrome versions",
"last 2 Firefox versions",
"last 2 Safari versions",
"last 2 Edge versions"
]
}
Impact: Saved ~12KB of polyfill code.
The Results
| Metric | Before | After |
|---|---|---|
| Performance (Desktop) | 55 | 94 |
| Performance (Mobile) | 45 | 78 |
| LCP (Desktop) | 4.4s | 1.3s |
| LCP (Mobile) | 5.6s | 2.8s |
| Total Blocking Time | 180ms | 20ms |
| CLS | 0.12 | 0 |
| SEO | 91 | 100 |
What's Next
We're still working on getting mobile Performance above 90. The main remaining bottleneck is third-party scripts (GTM alone accounts for ~126KB of unused JavaScript). If you find a way to make GTM lighter, let me know in the comments.
We're [Wevosoft], a web development agency in Tbilisi, Georgia specializing in Next.js, React, and modern web applications. Check out our [projects] or [book a free consultation].
Top comments (0)