Building Scalable Design Systems with React
A comprehensive guide to creating maintainable design systems.
Read ArticlePreparing digital magic...
Advanced techniques for optimizing Next.js applications, including code splitting, image optimization, and caching strategies.
Aime Claudien
Full-Stack Developer
Next.js Performance Optimization
Performance isn't a feature—it's a requirement. Studies consistently show that even 100ms of additional latency impacts user engagement and conversion rates.
When I first started building with Next.js, I was focused on shipping features quickly. The framework's defaults made this easy. But as my applications scaled, I started hitting performance bottlenecks that naive optimizations couldn't solve.
That's when I realized: Next.js gives you incredible tools for performance, but you need to understand them to leverage them effectively. The difference between a slow Next.js app and a blazing-fast one often comes down to three things: understanding how Next.js serves content, being intentional about what you ship to the browser, and measuring everything.
This post shares the advanced optimization techniques I've learned through building production applications that serve millions of requests. These are the strategies that took my apps from "good" to "genuinely fast."
Before optimizing, you need metrics. Google's Core Web Vitals are the industry standard:
Largest Contentful Paint (LCP) - Time until the largest visible element renders. Target: <2.5s
First Input Delay (FID) - Time between user interaction and response. Target: <100ms
(Note: Being replaced by Interaction to Next Paint - INP)
Cumulative Layout Shift (CLS) - Visual stability. Target: <0.1
First Contentful Paint (FCP) - When first content appears. Target: <1.8s
Time to First Byte (TTFB) - Server response time. Target: <600ms
I use three tools religiously:
1. **Lighthouse** - Built into Chrome DevTools, gives actionable recommendations
2. **WebPageTest** - Detailed waterfall charts and filmstrips
3. **Next.js Analytics** - Real user data from your production app
# Generate Lighthouse report from command line
npx lighthouse https://yoursite.com --view
My baseline approach: Start with Lighthouse, aim for 90+ score. Then use real user analytics to find the 80/20 issues.
One of the biggest performance wins comes from shipping less JavaScript. Next.js handles most code splitting automatically, but you need to be intentional.
1. Analyze Your Bundle
First, understand what you're shipping:
npm install --save-dev @next/bundle-analyzer# In next.config.ts const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', })
module.exports = withBundleAnalyzer({})
# Run it ANALYZE=true npm run build
This visualizes your bundle and reveals bloated dependencies. I once found a 200KB dependency that was barely used—removing it cut our bundle by 15%.
2. Dynamic Imports
Load components only when needed:
import dynamic from 'next/dynamic'// Heavy component only loads when needed const HeavyAnalyticsDashboard = dynamic( () => import('@/components/analytics-dashboard'), { loading: () => <div>Loading...</div> } )
export function Page() { const [showAnalytics, setShowAnalytics] = useState(false) return ( <> <button onClick={() => setShowAnalytics(true)}> Show Analytics </button> {showAnalytics && <HeavyAnalyticsDashboard />} </> ) }
3. Route-Based Code Splitting
Next.js automatically splits code per route. Verify with the bundle analyzer that pages aren't importing unnecessary code from other pages.
Real impact: I reduced initial page load from 85KB to 32KB by moving an analytics library to dynamic import, improving LCP by 1.2 seconds.
Images typically account for 50%+ of a webpage's bytes. Optimizing them is often the easiest 10x improvement.
Use Next.js Image Component
Never use `<img>` in Next.js. Always use the Image component:
import Image from 'next/image'export function HeroSection() { return ( <Image src="/hero.png" alt="Hero image" width={1200} height={600} priority // For above-the-fold images sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" placeholder="blur" // Shows blurred version while loading quality={75} // Balance between quality and size /> ) }
Key features:
- Automatic format selection (WebP, AVIF when supported)
- Responsive image serving based on device size
- Lazy loading by default
- `priority` prop for above-the-fold images
- Built-in blur placeholder
Advanced: Responsive Images with srcSet
import Image from 'next/image'export function ResponsiveGallery() { return ( <Image src="/large-image.png" alt="Gallery" width={1200} height={800} sizes=" (max-width: 640px) 100vw, (max-width: 1024px) 50vw, (max-width: 1280px) 33vw, 25vw " /> ) }
Optimization checklist:
- [ ] WebP format for all images
- [ ] Properly sized images (not 4000px wide for mobile)
- [ ] Lazy loading except above-the-fold
- [ ] Blur placeholders for social sharing
- [ ] Proper aspect ratios to prevent layout shift
Real impact: Switched 30+ images to Next.js Image component with responsive sizes. Saved 400KB and reduced LCP by 0.8s.
Caching is where performance compounds. Missing cache headers can mean re-downloading identical content.
1. Static Generation with Revalidation
Use ISR (Incremental Static Regeneration) for content that updates infrequently:
// app/blog/[slug]/page.tsx
export const revalidate = 3600 // Regenerate every hourexport default async function BlogPost({ params }) { const post = await getPost(params.slug) return <article>{post.content}</article> }
This generates static HTML at build time, serves it instantly, and regenerates in the background. Users always get fast static content.
2. Server-Side Caching with Headers
Control browser caching with response headers:
// app/api/data/route.ts
export async function GET() {
const data = await fetchExpensiveData()
return Response.json(data, {
headers: {
'Cache-Control': 'public, max-age=3600, s-maxage=86400',
// max-age: browser cache (1 hour)
// s-maxage: CDN cache (1 day)
},
})
}
3. On-Demand ISR
Regenerate pages when content changes:
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache'export async function POST(request) { const secret = request.headers.get('x-api-secret') if (secret !== process.env.REVALIDATE_SECRET) { return new Response('Unauthorized', { status: 401 }) } revalidatePath('/blog/[slug]', 'page') return Response.json({ revalidated: true }) }
Then call from your CMS when content changes:
// When content is published in CMS
await fetch('https://yoursite.com/api/revalidate', {
method: 'POST',
headers: { 'x-api-secret': REVALIDATE_SECRET }
})
Caching hierarchy I use:
- Static content (images, fonts): 1 year
- API responses: 1 hour (browser), 1 day (CDN)
- HTML pages: 1 minute (browser), 1 hour (CDN)
- Dynamic pages: No cache
Third-party scripts (analytics, ads, etc.) can tank performance. Next.js has a dedicated solution.
Use the Script Component
import Script from 'next/script'export function Layout() { return ( <> <Script src="https://cdn.example.com/analytics.js" strategy="lazyOnload" // Load after page is interactive onLoad={() => console.log('Analytics loaded')} /> <Script src="https://cdn.example.com/ads.js" strategy="afterInteractive" // Safe for ads /> <Script src="https://cdn.example.com/critical.js" strategy="beforeInteractive" // Only if truly needed /> </> ) }
Strategy explanations:
- **beforeInteractive**: Executes before hydration. Use rarely—only for critical scripts
- **afterInteractive**: Default. Safe for most third-party scripts
- **lazyOnload**: Loads after page interaction. Perfect for analytics and non-critical
- **worker**: Runs in Web Worker (experimental)
Real example: Analytics
import Script from 'next/script'export function AnalyticsScript() { return ( <Script src="https://www.googletagmanager.com/gtag/js?id=GA_ID" strategy="lazyOnload" onLoad={() => { window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', 'GA_ID'); }} /> ) }
Impact: Moving analytics to lazyOnload reduced my FID by 120ms because the script doesn't block user interactions.
Next.js 13+ App Router gives you three options per route. Choosing wisely is crucial for performance.
1. Static (SSG) - Fastest**
// app/docs/page.tsx
// Rendered at build time, served instantlyexport const revalidate = false // Cache indefinitely
export default async function DocsPage() { return <div>Fast static content</div> }
When to use: Blog posts, documentation, marketing pages, anything that doesn't change frequently
2. Dynamic with Caching (ISR)
// app/products/[id]/page.tsx
// Generated on-demand, cached, regenerated periodicallyexport const revalidate = 3600 // Regenerate every hour
export default async function ProductPage({ params }) { const product = await getProduct(params.id) return <ProductDetail product={product} /> }
When to use: E-commerce products, user profiles, content that changes but not constantly
3. Server-Side Rendering (SSR)
// app/dashboard/page.tsx
// Rendered on each requestexport const revalidate = 0 // No caching
export default async function Dashboard() { const user = await getCurrentUser() const data = await getUserData() return <Dashboard user={user} data={data} /> }
When to use: User-specific content, real-time data, authenticated pages
Performance impact:
- Static: 50ms response time
- ISR: 100-200ms response time (first request slower, then cached)
- SSR: 200-500ms (depends on data fetching)
Pro tip: Use streaming for slow data:
import { Suspense } from 'react'async function SlowComponent() { const data = await fetchSlowData() return <div>{data}</div> }
export default function Page() { return ( <> <FastHeader /> <Suspense fallback={<Skeleton />}> <SlowComponent /> </Suspense> </> ) }
This renders the page immediately while slow data loads—users see content faster.
Performance optimization isn't a one-time task—it's continuous. You need visibility into your metrics.
1. Core Web Vitals in Production
// app/layout.tsx
import { Analytics } from '@vercel/analytics/react'export default function Layout({ children }) { return ( <html> <body> {children} <Analytics /> </body> </html> ) }
2. Custom Performance Tracking
// lib/performance.ts
export function trackMetric(name: string, value: number) {
// Send to your analytics service
window.gtag?.('event', 'performance', {
metric_name: name,
metric_value: value,
})
}// Usage in components useEffect(() => { const start = performance.now() return () => { const duration = performance.now() - start trackMetric('component_render_time', duration) } }, [])
3. Performance Budgets
Set maximum allowed bundle sizes:
// next.config.ts
import { NextConfig } from 'next'const config: NextConfig = { typescript: { tsconfigPath: './tsconfig.json' }, }
// Add to package.json scripts // "size-limit": "size-limit",
// .size-limit.json [ { "path": ".next/static/chunks/main*.js", "limit": "50 KB" }, { "path": ".next/static/chunks/app-*.js", "limit": "100 KB" } ]
My monitoring dashboard setup:
- Weekly Lighthouse scores
- Daily Core Web Vitals averages
- Performance budget alerts
- User experience metrics by country
Treat performance like a product metric. When metrics regress, investigate immediately.
Images
- [ ] Using Next.js Image component everywhere
- [ ] Responsive images with sizes prop
- [ ] Blur placeholder for above-fold images
- [ ] Priority prop for LCP images
- [ ] Quality set to 75-85%
JavaScript
- [ ] Bundle analyzed with @next/bundle-analyzer
- [ ] Heavy components dynamic imported
- [ ] Third-party scripts use appropriate strategy
- [ ] No unused dependencies
- [ ] Tree-shaking enabled
Caching
- [ ] Static pages use ISR
- [ ] Cache headers set appropriately
- [ ] CDN configured
- [ ] On-demand ISR for content updates
- [ ] Service Worker for offline support
Rendering
- [ ] Static where possible
- [ ] ISR for semi-static content
- [ ] Streaming for slow queries
- [ ] No unnecessary SSR
- [ ] Database queries optimized
Monitoring
- [ ] Analytics configured
- [ ] Lighthouse monitored weekly
- [ ] Core Web Vitals tracked
- [ ] Performance budget enforced
- [ ] Alerts set for regressions
Testing
- [ ] Lighthouse score >90
- [ ] LCP <2.5s
- [ ] FID <100ms
- [ ] CLS <0.1
- [ ] First Byte <600ms
Quick wins I recommend for any project:
1. Implement Next.js Image for all images (1-2 hours, massive impact)
2. Add analytics (30 min, but invaluable)
3. Move third-party to lazyOnload (15 min, real improvements)
4. Review and optimize imports (1 hour, 5-20% bundle reduction)
5. Set up bundle analyzer (5 min setup, ongoing insights)
I've taken applications from Lighthouse scores of 45 to 95+. It's not magic—it's understanding the tools Next.js gives you and applying them systematically.
Key principles I've learned:
1. **Measure first** - You can't optimize what you don't measure. Get Lighthouse and real user analytics set up immediately.
2. **Think in layers** - Optimize static content first (highest impact, easiest). Then caching. Then rendering strategy. Then JavaScript.
3. **User experience matters more than numbers** - Core Web Vitals matter because they impact how users feel your site. A Lighthouse score of 85 with good real user experience beats 95 with poor caching.
4. **Performance is continuous** - Regressions happen. Monitor regularly and address issues quickly.
5. **Next.js does the heavy lifting** - The framework handles most optimization automatically. Your job is to not sabotage it and to use its tools effectively.
The business case:
Performance improvements correlate directly with business metrics:
- 100ms faster = 1% more conversions (Google)
- Every second of improvement = 7% more conversions (Amazon)
For my projects, optimizing from average to top 10% performance translated to 3-8% revenue improvement on e-commerce, and 15-20% improvement in user engagement on content sites.
Final thought:
The difference between a slow app and a fast one often isn't more work—it's intentionality. Making good choices about images, caching, and rendering strategy takes minimal additional effort but compounds into massive performance gains.
Start today. Measure your current performance, pick one optimization from this post, implement it, and measure the impact. You'll be amazed at what's possible.
Here's to building fast, delightful user experiences with Next.js.