Performance Optimization
Optimize your ProductReady app for speed - Core Web Vitals, caching, code splitting, and database optimization
Performance Optimization
Make your ProductReady app blazingly fast with Next.js 15+ optimization techniques. Learn how to improve Core Web Vitals, database performance, and user experience.
Performance = SEO + UX: Fast sites rank higher in Google and convert better. Every 100ms delay costs 1% conversion.
Quick Overview
Key metrics (Core Web Vitals):
- ✅ LCP (Largest Contentful Paint) - Loading speed < 2.5s
- ✅ FID/INP (First Input Delay / Interaction to Next Paint) - Interactivity < 200ms
- ✅ CLS (Cumulative Layout Shift) - Visual stability < 0.1
Optimization areas:
- ✅ Image & font optimization
- ✅ Code splitting & lazy loading
- ✅ Caching strategies
- ✅ Database query optimization
- ✅ API response time
- ✅ Bundle size reduction
Measuring Performance
Tools
-
Lighthouse (Chrome DevTools)
- Press F12 → Lighthouse tab → Generate report
- Provides actionable recommendations
-
PageSpeed Insights
- https://pagespeed.web.dev/
- Real-world data from Chrome users
-
Vercel Analytics (if deployed to Vercel)
- Real user monitoring (RUM)
- Tracks Core Web Vitals automatically
-
Chrome DevTools Performance
- Record page load
- Identify bottlenecks
Web Vitals in Code
// src/app/layout.tsx
import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/next';
export default function RootLayout({ children }: Props) {
return (
<html>
<body>
{children}
<Analytics />
<SpeedInsights />
</body>
</html>
);
}Image Optimization
Next.js Image Component
Always use <Image> instead of <img>:
import Image from 'next/image';
// ✅ Good: Optimized automatically
<Image
src="/hero.png"
alt="Hero image"
width={1200}
height={630}
priority // For above-the-fold images
placeholder="blur"
blurDataURL="data:image/png;base64,..." // Low-quality placeholder
/>
// ❌ Bad: No optimization
<img src="/hero.png" alt="Hero" />Priority Loading
Mark critical images as priority:
// Hero image (above the fold)
<Image src="/hero.png" priority alt="Hero" width={1200} height={630} />
// Below the fold images (lazy load by default)
<Image src="/feature.png" alt="Feature" width={800} height={600} />Responsive Images
<Image
src="/hero.png"
alt="Hero"
width={1200}
height={630}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>Remote Images
// next.config.mjs
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.unsplash.com',
},
{
protocol: 'https',
hostname: 'avatars.githubusercontent.com',
},
],
},
};Font Optimization
Next.js Font Loading
// src/app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap', // Prevent invisible text
variable: '--font-inter',
});
const robotoMono = Roboto_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-roboto-mono',
});
export default function RootLayout({ children }: Props) {
return (
<html className={`${inter.variable} ${robotoMono.variable}`}>
<body className="font-sans">{children}</body>
</html>
);
}Local Fonts
import localFont from 'next/font/local';
const myFont = localFont({
src: './my-font.woff2',
display: 'swap',
variable: '--font-custom',
});Preload Fonts
// src/app/layout.tsx
export default function RootLayout({ children }: Props) {
return (
<html>
<head>
<link
rel="preload"
href="/fonts/inter-var.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
</head>
<body>{children}</body>
</html>
);
}Code Splitting & Lazy Loading
Dynamic Imports
import dynamic from 'next/dynamic';
// Lazy load heavy components
const HeavyChart = dynamic(() => import('~/components/heavy-chart'), {
loading: () => <div>Loading chart...</div>,
ssr: false, // Skip SSR if not needed for SEO
});
export function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<HeavyChart /> {/* Only loaded when component mounts */}
</div>
);
}Route-Based Code Splitting
Next.js automatically splits code by route - no configuration needed!
src/app/
├── page.tsx # Bundle 1: Home
├── dashboard/
│ └── page.tsx # Bundle 2: Dashboard
└── settings/
└── page.tsx # Bundle 3: SettingsConditional Imports
'use client';
import { useState } from 'react';
export function Editor() {
const [editor, setEditor] = useState(null);
const loadEditor = async () => {
// Only import when user clicks "Edit"
const { Editor } = await import('~/components/rich-editor');
setEditor(<Editor />);
};
return (
<div>
<button onClick={loadEditor}>Edit</button>
{editor}
</div>
);
}Caching Strategies
Static Pages (ISR)
Rebuild pages periodically:
// src/app/posts/[slug]/page.tsx
export const revalidate = 3600; // Revalidate every hour
export default async function PostPage({ params }: Props) {
const post = await fetchPost(params.slug);
return <div>{post.title}</div>;
}API Route Caching
// src/app/api/data/route.ts
export async function GET() {
const data = await fetchData();
return Response.json(data, {
headers: {
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
});
}tRPC Response Caching
// src/server/routers/posts.ts
export const postsRouter = createTRPCRouter({
list: publicProcedure
.query(async ({ ctx }) => {
const posts = await ctx.db.query.posts.findMany();
// Cache for 5 minutes
ctx.resHeaders?.set(
'Cache-Control',
'public, s-maxage=300, stale-while-revalidate=600'
);
return posts;
}),
});React Query Caching
'use client';
import { trpc } from '~/lib/trpc/client';
export function Posts() {
const { data } = trpc.posts.list.useQuery(undefined, {
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
});
return <div>{/* Render posts */}</div>;
}Database Optimization
Query Optimization
❌ Bad: N+1 queries
const posts = await db.query.posts.findMany();
for (const post of posts) {
const author = await db.query.users.findFirst({
where: eq(users.id, post.authorId),
});
post.author = author; // N queries!
}✅ Good: Join in single query
const posts = await db.query.posts.findMany({
with: {
author: true, // Single query with JOIN
},
});Indexes
Add indexes for frequently queried fields:
// src/db/schema/posts.ts
import { pgTable, text, timestamp, index } from 'drizzle-orm/pg-core';
export const posts = pgTable('posts', {
id: text('id').primaryKey(),
authorId: text('author_id').notNull(),
slug: text('slug').notNull(),
createdAt: timestamp('created_at').defaultNow(),
}, (table) => ({
// Index for faster lookups
slugIdx: index('slug_idx').on(table.slug),
authorIdx: index('author_idx').on(table.authorId),
createdAtIdx: index('created_at_idx').on(table.createdAt),
}));Connection Pooling
Use connection pooling for better performance:
# Neon (built-in pooling)
PG_DATABASE_URL=postgresql://user:pass@host.neon.tech:5432/db?sslmode=require
# Supabase (transaction mode for Drizzle)
PG_DATABASE_URL=postgresql://postgres.[ref]:[pass]@aws-0-[region].pooler.supabase.com:6543/postgres
# Or use PgBouncer
docker run -d -p 6432:6432 \
-e DATABASE_URL=postgresql://user:pass@postgres:5432/db \
edoburu/pgbouncerPagination
❌ Bad: Load all records
const posts = await db.query.posts.findMany(); // Could be millions!✅ Good: Paginate
const posts = await db.query.posts.findMany({
limit: 20,
offset: page * 20,
orderBy: [desc(posts.createdAt)],
});Select Only Needed Fields
// ❌ Bad: Select all fields
const users = await db.select().from(users);
// ✅ Good: Select only needed fields
const users = await db.select({
id: users.id,
name: users.name,
email: users.email,
}).from(users);Bundle Size Optimization
Analyze Bundle
# Build with analysis
pnpm build
# Install analyzer
pnpm add -D @next/bundle-analyzer
# Configure
// next.config.mjs
import bundleAnalyzer from '@next/bundle-analyzer';
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
});
export default withBundleAnalyzer(nextConfig);
# Run analysis
ANALYZE=true pnpm buildTree Shaking
Import only what you need:
// ❌ Bad: Imports entire library
import _ from 'lodash';
const result = _.map(array, fn);
// ✅ Good: Tree-shakeable import
import { map } from 'lodash-es';
const result = map(array, fn);
// ✅ Better: Use native methods
const result = array.map(fn);Remove Unused Dependencies
# Find unused dependencies
npx depcheck
# Remove them
pnpm remove unused-packageReact Performance
Memoization
import { memo, useMemo, useCallback } from 'react';
// Memoize expensive components
export const ExpensiveComponent = memo(function ExpensiveComponent({ data }: Props) {
const processedData = useMemo(() => {
return expensiveProcessing(data);
}, [data]);
return <div>{processedData}</div>;
});
// Memoize callbacks
export function ParentComponent() {
const handleClick = useCallback(() => {
console.log('clicked');
}, []); // Stable reference
return <ExpensiveComponent onClick={handleClick} />;
}Virtual Lists
For long lists, use virtualization:
pnpm add @tanstack/react-virtualimport { useVirtualizer } from '@tanstack/react-virtual';
export function VirtualList({ items }: Props) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
});
return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px` }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.index}
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{items[virtualRow.index]}
</div>
))}
</div>
</div>
);
}Streaming & Suspense
Streaming Server Components
// src/app/dashboard/page.tsx
import { Suspense } from 'react';
async function SlowData() {
const data = await fetchSlowData(); // 3 seconds
return <div>{data}</div>;
}
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<div>Loading...</div>}>
<SlowData /> {/* Streams in when ready */}
</Suspense>
</div>
);
}Loading States
// src/app/dashboard/loading.tsx
export default function Loading() {
return <div>Loading dashboard...</div>;
}CDN & Edge
Edge Runtime
Run API routes on the edge for lower latency:
// src/app/api/hello/route.ts
export const runtime = 'edge';
export async function GET() {
return Response.json({ hello: 'world' });
}Static Asset CDN
Vercel automatically serves static assets from CDN. For self-hosted:
# nginx config
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}Monitoring
Real User Monitoring (RUM)
// src/app/layout.tsx
import { Analytics } from '@vercel/analytics/react';
export default function RootLayout({ children }: Props) {
return (
<html>
<body>
{children}
<Analytics />
</body>
</html>
);
}Custom Metrics
export function reportWebVitals(metric: NextWebVitalsMetric) {
// Send to analytics
fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify(metric),
});
}Checklist
Images
- Use Next.js
<Image>component - Add
priorityto above-the-fold images - Use appropriate sizes
- Serve WebP format
Fonts
- Use
next/fontfor optimization - Set
display: 'swap' - Preload critical fonts
Code
- Lazy load heavy components
- Split code by route
- Remove unused dependencies
- Tree-shake imports
Caching
- Enable ISR for static pages
- Cache API responses
- Use React Query caching
Database
- Add indexes to frequently queried fields
- Use connection pooling
- Paginate large result sets
- Avoid N+1 queries
Bundle
- Analyze bundle size
- Remove unused code
- Use tree-shakeable imports
Quick Wins
30 seconds:
- Add
<Analytics />component
5 minutes:
- Replace
<img>with<Image> - Add
priorityto hero images - Use
next/fontfor fonts
30 minutes:
- Add database indexes
- Enable ISR on static pages
- Lazy load heavy components
2 hours:
- Analyze and reduce bundle size
- Optimize database queries
- Add React Query caching
Next Steps
- Measure first - Run Lighthouse to identify issues
- Fix one at a time - Don't optimize prematurely
- Monitor continuously - Track Core Web Vitals
- Test on real devices - Mobile performance matters
Focus on Core Web Vitals first - they directly impact SEO and user experience. Start with images and fonts, they're the easiest wins.