Why Performance Matters
A slow website doesn't just frustrate users ~ it costs you traffic, conversions, and credibility. Google uses Core Web Vitals as a ranking signal, and users expect pages to load in under 2 seconds. Here are 7 proven strategies to supercharge your React/Next.js application.
1. Use Server Components by Default
Next.js 13+ introduced React Server Components (RSC) as the default in the App Router. Server Components render on the server and send zero JavaScript to the client.
Why it matters: Less JavaScript = faster page loads. Only add"use client" when you need interactivity (event handlers, hooks, browser APIs).
// ✅ Server Component (default — no directive needed)
export default function BlogList({ posts }) {
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
// ❌ Only use "use client" when you actually need client interactivity
"use client";
export function LikeButton() {
const [liked, setLiked] = useState(false);
return <button onClick={() => setLiked(!liked)}>♥</button>;
}
Rule of thumb: Push "use client" as far down the component tree as possible.
2. Optimize Images with next/image
The next/image component automatically handles lazy loading, responsive sizing, and modern format conversion (WebP/AVIF).
import Image from "next/image";
export function HeroImage() {
return (
<Image
src="/hero.jpg"
alt="Hero banner"
width={1200}
height={600}
priority // Only for above-the-fold images
placeholder="blur"
blurDataURL="/hero-blur.jpg"
/>
);
}
Key tips:
- Use
priorityonly for LCP (Largest Contentful Paint) images - Always specify
widthandheightto prevent layout shift - Use
placeholder="blur"for a smooth loading experience - Serve images in WebP or AVIF format for smaller file sizes
3. Implement Code Splitting & Dynamic Imports
Don't load everything upfront. Use next/dynamic to split heavy components out of your main bundle.
import dynamic from "next/dynamic";
// Heavy chart library only loads when the component mounts
const AnalyticsChart = dynamic(
() => import("@/components/AnalyticsChart"),
{
loading: () => <div className="h-64 animate-pulse bg-muted rounded" />,
ssr: false, // Skip server rendering if it's a client-only component
}
);
When to use dynamic imports:
- Heavy visualization libraries (D3, Chart.js, Recharts)
- Modals and dialogs that aren't immediately visible
- Rich text editors
- Any component that uses browser-only APIs
4. Lazy Load Below-the-Fold Sections
Sections that are not visible on initial page load should use Intersection Observer to defer rendering.
"use client";
import { useRef } from "react";
import { motion, useInView } from "framer-motion";
export function LazySection({ children }) {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: "-100px" });
return (
<div ref={ref}>
{isInView ? (
<motion.div
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
{children}
</motion.div>
) : (
<div className="min-h-[400px]" /> // Placeholder height
)}
</div>
);
}
This keeps your initial paint fast and only loads content as the user scrolls to it.
5. Minimize Bundle Size — Avoid Heavy Libraries
Every kilobyte matters. Before adding a library, ask: Can I do this with native browser APIs or a lighter alternative?
| Heavy Library | Lighter Alternative |
|---|---|
| Moment.js (300KB) | date-fns or dayjs (2-7KB) |
| Lodash (72KB) | Native array methods |
| Animate.css | CSS @keyframes |
| Axios (13KB) | Native fetch() |
| jQuery | querySelector + fetch |
# Check what's bloating your bundle
npx @next/bundle-analyzer
6. Use React.memo, useMemo, and useCallback Wisely
Unnecessary re-renders are a silent performance killer. Use memoization strategically:
import { memo, useMemo, useCallback } from "react";
// Memoize expensive child components
const ProductCard = memo(function ProductCard({ product }) {
return <div>{product.name} — ${product.price}</div>;
});
export function ProductList({ products, onSelect }) {
// Memoize filtered results
const expensiveProducts = useMemo(
() => products.filter(p => p.price > 100),
[products]
);
// Stable callback reference
const handleSelect = useCallback(
(id) => onSelect(id),
[onSelect]
);
return expensiveProducts.map(p => (
<ProductCard
key={p.id}
product={p}
onSelect={handleSelect}
/>
));
}
When NOT to memoize:
- Simple, lightweight components
- Values that change on every render anyway
- Premature optimization without profiling first
7. Enable Static Generation (SSG) Where Possible
If a page's content doesn't change on every request, pre-render it at build time:
// app/blog/[slug]/page.tsx — Server Component
// Pre-generate all blog post pages at build time
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map(post => ({ slug: post.slug }));
}
export default async function BlogPost({ params }) {
const post = await getPostBySlug(params.slug);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
Bonus strategies:
- Use
revalidatefor ISR (Incremental Static Regeneration) when data updates periodically - Pre-fetch links with
for instant navigation - Use
loading.tsxfor streaming UI while data loads
Bonus: Quick Performance Checklist
- Lighthouse score > 95 on all metrics
- No layout shift (CLS < 0.1)
- First Contentful Paint < 1.8s
- Use
next/fontfor zero-flash font loading - Compress assets with gzip/brotli
- Minimize third-party scripts
- Use CDN for static assets
Wrapping Up
Performance is a journey, not a one-time task. Continuously measure with Lighthouse, Web Vitals, and real user monitoring. The strategies above have helped me achieve consistent 95+ Lighthouse scores on production Next.js applications — and they can do the same for yours.
Happy optimizing! 🚀