A practical guide for developers who want to ship fast, performant applications
After 3+ years of building React applications with Next.js and TypeScript, I've learned that performance optimization isn't just about fancy metrics—it's about creating delightful user experiences that convert better and cost less to maintain. Here's the checklist I wish I had when I started.
The Problem: Your main content takes forever to load.
Quick Wins:
// ❌ Bad: Large images without optimization
<img src="/hero-image.jpg" alt="Hero" />
// ✅ Good: Next.js Image component with priority
import Image from 'next/image'
<Image
src="/hero-image.jpg"
alt="Hero"
width={1200}
height={600}
priority
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
/>
Advanced: Use next/dynamic for heavy components:
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <ChartSkeleton />,
ssr: false
})
The Problem: Your app feels unresponsive to user interactions.
React-Specific Solutions:
// ❌ Bad: Blocking the main thread
const ExpensiveComponent = ({ data }: { data: LargeDataSet }) => {
const processedData = data.map(item => heavyProcessing(item))
return <div>{/* render */}</div>
}
// ✅ Good: Use useMemo for expensive calculations
const ExpensiveComponent = ({ data }: { data: LargeDataSet }) => {
const processedData = useMemo(
() => data.map(item => heavyProcessing(item)),
[data]
)
return <div>{/* render */}</div>
}
// ✅ Better: Use React.startTransition for non-urgent updates
const [isPending, startTransition] = useTransition()
const handleSearch = (query: string) => {
setQuery(query) // Urgent: update input
startTransition(() => {
setResults(searchResults(query)) // Non-urgent: update results
})
}
The Problem: Your layout jumps around as content loads.
TypeScript-Enhanced Solution:
// Define consistent sizing interfaces
interface ImageDimensions {
width: number
height: number
aspectRatio: string
}
const ImageContainer = styled.div<ImageDimensions>`
width: ${props => props.width}px;
height: ${props => props.height}px;
aspect-ratio: ${props => props.aspectRatio};
`
// Always reserve space for dynamic content
const ProductCard: React.FC<{ product?: Product }> = ({ product }) => {
if (!product) {
return <ProductCardSkeleton /> // Same dimensions as actual card
}
return (
<ImageContainer width={300} height={200} aspectRatio="3/2">
<Image src={product.image} alt={product.name} fill />
</ImageContainer>
)
}
interface ExpensiveListProps {
items: Item[]
onItemClick: (id: string) => void
}
// ❌ Bad: Re-renders every item on any change
const ExpensiveList: React.FC<ExpensiveListProps> = ({ items, onItemClick }) => {
return (
<div>
{items.map(item => (
<ExpensiveItem
key={item.id}
item={item}
onClick={() => onItemClick(item.id)}
/>
))}
</div>
)
}
// ✅ Good: Memoized with stable references
const ExpensiveList: React.FC<ExpensiveListProps> = ({ items, onItemClick }) => {
const handleItemClick = useCallback((id: string) => {
onItemClick(id)
}, [onItemClick])
return (
<div>
{items.map(item => (
<MemoizedExpensiveItem
key={item.id}
item={item}
onItemClick={handleItemClick}
/>
))}
</div>
)
}
const MemoizedExpensiveItem = React.memo(ExpensiveItem)
// ❌ Bad: Monolithic component
const Dashboard = () => {
const [userData, setUserData] = useState<User | null>(null)
const [analytics, setAnalytics] = useState<Analytics | null>(null)
const [notifications, setNotifications] = useState<Notification[]>([])
// All data fetching in one component...
// Component re-renders when any state changes
}
// ✅ Good: Split by data dependencies
const Dashboard = () => {
return (
<>
<UserProfile /> {/* Only re-renders when user data changes */}
<AnalyticsDashboard /> {/* Only re-renders when analytics change */}
<NotificationCenter /> {/* Only re-renders when notifications change */}
</>
)
}
import { FixedSizeList as List } from 'react-window'
interface VirtualizedListProps {
items: LargeDataItem[]
}
const VirtualizedList: React.FC<VirtualizedListProps> = ({ items }) => {
const Row = ({ index, style }: { index: number; style: CSSProperties }) => (
<div style={style}>
<ItemComponent item={items[index]} />
</div>
)
return (
<List
height={600}
itemCount={items.length}
itemSize={80}
width="100%"
>
{Row}
</List>
)
}
// Static Generation (Best for SEO + Performance)
export const getStaticProps: GetStaticProps = async () => {
const data = await fetchStaticData()
return {
props: { data },
revalidate: 3600 // ISR: Revalidate every hour
}
}
// Server-Side Rendering (For dynamic, personalized content)
export const getServerSideProps: GetServerSideProps = async (context) => {
const userSession = await getSession(context)
const personalizedData = await fetchUserData(userSession.userId)
return {
props: { personalizedData }
}
}
// Client-Side (For dashboard-like apps)
const Dashboard = () => {
const { data, error } = useSWR('/api/dashboard', fetcher, {
refreshInterval: 30000, // Refresh every 30 seconds
revalidateOnFocus: false
})
if (!data) return <DashboardSkeleton />
return <DashboardContent data={data} />
}
// next.config.js
const nextConfig = {
experimental: {
modern: true,
serverComponents: true
},
webpack: (config, { dev, isServer }) => {
// Analyze bundle in development
if (!dev && !isServer) {
config.resolve.alias = {
...config.resolve.alias,
// Replace heavy libraries with lighter alternatives
'moment': 'dayjs',
'lodash': 'lodash-es'
}
}
return config
},
// Enable compression
compress: true,
// Optimize images
images: {
formats: ['image/webp', 'image/avif'],
minimumCacheTTL: 31536000
}
}
// pages/api/products.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { unstable_cache } from 'next/cache'
const getCachedProducts = unstable_cache(
async () => {
const products = await db.products.findMany()
return products
},
['products'],
{ revalidate: 3600 } // Cache for 1 hour
)
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
// Set cache headers
res.setHeader(
'Cache-Control',
'public, s-maxage=3600, stale-while-revalidate=86400'
)
try {
const products = await getCachedProducts()
res.status(200).json(products)
} catch (error) {
res.status(500).json({ error: 'Failed to fetch products' })
}
}
// Define component props with loading states
interface ComponentProps<T> {
data?: T
loading?: boolean
error?: Error | null
}
// Generic lazy loading hook
function useLazyLoad<T>(
loader: () => Promise<T>,
deps: React.DependencyList = []
): ComponentProps<T> {
const [state, setState] = useState<ComponentProps<T>>({ loading: true })
useEffect(() => {
let cancelled = false
loader()
.then(data => {
if (!cancelled) setState({ data, loading: false })
})
.catch(error => {
if (!cancelled) setState({ error, loading: false })
})
return () => { cancelled = true }
}, deps)
return state
}
// Usage with type safety
const MyComponent = () => {
const { data, loading, error } = useLazyLoad<User[]>(
() => import('./api/users').then(m => m.fetchUsers()),
[]
)
if (loading) return <Skeleton />
if (error) return <ErrorBoundary error={error} />
return <UserList users={data!} />
}
// Define performance constraints at the type level
interface PerformantComponentProps {
maxItems?: number
virtualized?: boolean
memoized?: boolean
}
type OptimizedListProps<T> = {
items: T[]
renderItem: (item: T) => React.ReactNode
} & PerformantComponentProps
const OptimizedList = <T extends { id: string }>({
items,
renderItem,
maxItems = 100,
virtualized = false,
memoized = true
}: OptimizedListProps<T>) => {
// Warn about performance issues at compile time
if (items.length > maxItems && !virtualized) {
console.warn(`Consider enabling virtualization for ${items.length} items`)
}
const ItemComponent = memoized ?
React.memo(({ item }: { item: T }) => <>{renderItem(item)}</>) :
({ item }: { item: T }) => <>{renderItem(item)}</>
if (virtualized) {
return <VirtualizedList items={items} ItemComponent={ItemComponent} />
}
return (
<div>
{items.slice(0, maxItems).map(item => (
<ItemComponent key={item.id} item={item} />
))}
</div>
)
}
// utils/performance.ts
export const measurePerformance = (name: string) => {
if (typeof window !== 'undefined' && window.performance) {
return {
start: () => performance.mark(`${name}-start`),
end: () => {
performance.mark(`${name}-end`)
performance.measure(name, `${name}-start`, `${name}-end`)
const measure = performance.getEntriesByName(name)[0]
console.log(`${name}: ${measure.duration}ms`)
}
}
}
return { start: () => {}, end: () => {} }
}
// Hook for component performance
export const usePerformanceProfiler = (componentName: string) => {
useEffect(() => {
const profiler = measurePerformance(componentName)
profiler.start()
return profiler.end
}, [componentName])
}
// components/PerformanceMonitor.tsx
import { Profiler, ProfilerOnRenderCallback } from 'react'
const onRenderCallback: ProfilerOnRenderCallback = (
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime
) => {
// Only log slow renders in development
if (process.env.NODE_ENV === 'development' && actualDuration > 16) {
console.warn(`Slow render detected in ${id}:`, {
phase,
actualDuration,
baseDuration
})
}
}
export const PerformanceMonitor: React.FC<{ children: React.ReactNode }> = ({
children
}) => {
return (
<Profiler id="App" onRender={onRenderCallback}>
{children}
</Profiler>
)
}
Images: Optimized, correct format (WebP/AVIF), proper sizing
Components: Memoized expensive renders, stable callback references
Data Fetching: Cached appropriately, loading states implemented
Bundle: No unnecessary dependencies, code-splitting applied
Metrics: Core Web Vitals measured, performance budget respected
TypeScript: Performance constraints encoded in types where applicable
Lighthouse CI: Automated performance testing in CI/CD
Bundle Analyzer: Regular bundle size monitoring
Real User Metrics: Track actual user experience, not just lab metrics
Performance Budget: Set and enforce performance budgets
Performance optimization is not about chasing perfect scores—it's about understanding your users and their constraints. A 90 Lighthouse score that loads instantly on 3G is better than a 100 score that takes 5 seconds on mobile.
Focus on:
Measure first: Don't optimize what you can't measure
User-centric metrics: Care about what users feel, not just what tools report
Progressive enhancement: Make it work, then make it fast
Sustainable practices: Build performance into your development process
The best performance optimization is the one your users notice—because your app just works, fast, everywhere.
0
2
1