The Complete Next.js Performance Optimization Guide

I am a 18 y/o web developer , User Interface Designer and Python Programmer from India.
I love to share my experiences with people all over the internet.
Connect with me and have a great time reading the blogs.
A Production-Ready Template for Making Your Next.js Apps Lightning Fast
Introduction
This guide documents proven strategies to optimize Next.js applications for production. These techniques reduced our API response times by 60-70% and improved page load times by 50%.
You might see some file names and codes and not just general code. All that code is actually from My github project . I learnt all this while trying to optimize that app.
All content is written by claude btw.
What You'll Learn
β Backend API optimization (rate limiting, error handling, logging)
β Database query optimization (indexes, N+1 queries, connection pooling)
β Frontend performance (code splitting, lazy loading, prefetching)
β Build optimization (bundle analysis, tree shaking)
β Caching strategies (Redis, CDN, browser cache)
β Image optimization (Next.js Image, WebP, responsive images)
β Deployment strategies (edge functions, static generation)
Complete Optimization Checklist
π΄ Critical (Must Do)
[ ] Rate Limiting - Prevent API abuse
[ ] Database Indexes - Add indexes on frequently queried fields
[ ] Error Handling - Centralized error handling with proper status codes
[ ] Input Validation - Validate all user inputs
[ ] Caching Layer - Implement Redis caching for expensive queries
[ ] Image Optimization - Use Next.js Image component
[ ] Code Splitting - Dynamic imports for heavy components
[ ] Environment Variables - Secure API keys and sensitive data
π‘ Important (High Impact)
[ ] Connection Pooling - Configure database connection pool
[ ] N+1 Query Fixes - Use includes/joins instead of loops
[ ] Pagination - Implement pagination for large datasets
[ ] Lazy Loading - Lazy load below-fold images and components
[ ] Bundle Analysis - Analyze and reduce bundle size
[ ] Static Generation - Use SSG where possible
[ ] Response Compression - Enable gzip/brotli
[ ] Logging - Structured logging for debugging
[ ] Cache Invalidation - Proper cache invalidation strategy
π’ Nice to Have (Optimization)
[ ] Service Worker - PWA capabilities with offline support
[ ] Prefetching - Prefetch next page data
[ ] Virtual Lists - Virtualization for long lists
[ ] GraphQL - Consider GraphQL for complex data fetching
[ ] Edge Functions - Deploy API routes to edge locations
[ ] Request Batching - Batch multiple API calls
[ ] Monitoring - Set up performance monitoring (Sentry, Datadog)
[ ] A/B Testing - Implement feature flags
[ ] Database Read Replicas - Separate read/write databases
[ ] CDN - Use CDN for static assets
π Measurement & Monitoring
[ ] Lighthouse Score - Aim for 90+ performance score
[ ] Core Web Vitals - Monitor LCP, FID, CLS
[ ] Real User Monitoring - Track actual user performance
[ ] Error Tracking - Set up Sentry or similar
[ ] API Performance - Log and monitor API response times
[ ] Database Queries - Log slow queries (>100ms)
[ ] Cache Hit Rate - Monitor cache effectiveness
Performance Measurement
π― Before You Optimize - Measure Everything!
Rule #1: Never optimize without measurements. You need baseline metrics to track improvements.
Tools for Measurement
1. Lighthouse (Built into Chrome DevTools)
# Run Lighthouse from CLI
npm install -g lighthouse
lighthouse https://your-app.com --view
Key Metrics:
Performance Score (target: 90+)
First Contentful Paint (FCP) - target: < 1.8s
Largest Contentful Paint (LCP) - target: < 2.5s
Time to Interactive (TTI) - target: < 3.8s
Cumulative Layout Shift (CLS) - target: < 0.1
2. Vercel Analytics (For Production)
// app/layout.tsx
import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/next';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
<SpeedInsights />
</body>
</html>
);
}
3. Custom Performance Monitoring
// lib/performance.ts
export class PerformanceMonitor {
private static timings = new Map<string, number>();
static start(label: string) {
this.timings.set(label, Date.now());
}
static end(label: string): number {
const start = this.timings.get(label);
if (!start) return 0;
const duration = Date.now() - start;
this.timings.delete(label);
console.log(`[Performance] ${label}: ${duration}ms`);
return duration;
}
static async measure<T>(
label: string,
fn: () => Promise<T>
): Promise<T> {
this.start(label);
try {
return await fn();
} finally {
this.end(label);
}
}
}
// Usage in API routes
export async function GET(request: NextRequest) {
return PerformanceMonitor.measure('GET /api/users', async () => {
const users = await prisma.user.findMany();
return NextResponse.json({ users });
});
}
Backend Optimization
1. Rate Limiting
Why: Prevent abuse, protect your API from DDoS, ensure fair usage.
Implementation with Upstash:
// lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
// Define rate limit tiers
export const rateLimiters = {
// Authentication endpoints - strictest
auth: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, "1 m"),
analytics: true,
prefix: "ratelimit:auth",
}),
// Write operations - moderate
write: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(20, "1 m"),
analytics: true,
prefix: "ratelimit:write",
}),
// Read operations - lenient
read: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(60, "1 m"),
analytics: true,
prefix: "ratelimit:read",
}),
// Real-time endpoints - very lenient
realtime: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(120, "1 m"),
analytics: true,
prefix: "ratelimit:realtime",
}),
};
// Helper to get client identifier
export async function getClientIdentifier(
request: NextRequest
): Promise<string> {
// Try to get user ID from session
const session = await getServerSession(authOptions);
if (session?.user?.id) {
return `user:${session.user.id}`;
}
// Fallback to IP address
const forwarded = request.headers.get("x-forwarded-for");
const ip = forwarded ? forwarded.split(",")[0] : "unknown";
return `ip:${ip}`;
}
// Wrapper function for routes
export async function withRateLimit(
request: NextRequest,
limiter: Ratelimit
) {
const identifier = await getClientIdentifier(request);
const { success, limit, reset, remaining } = await limiter.limit(identifier);
return {
success,
identifier,
headers: {
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": remaining.toString(),
"X-RateLimit-Reset": new Date(reset).toISOString(),
},
};
}
// Create response for rate limit exceeded
export function createRateLimitResponse(headers: Record<string, string>) {
return NextResponse.json(
{
error: "Too many requests",
message: "You have exceeded the rate limit. Please try again later.",
},
{
status: 429,
headers: {
...headers,
"Retry-After": "60",
},
}
);
}
Usage in API Routes:
// app/api/users/route.ts
import { rateLimiters, withRateLimit, createRateLimitResponse } from "@/lib/rate-limit";
export async function GET(request: NextRequest) {
// Apply rate limiting
const { success, headers } = await withRateLimit(request, rateLimiters.read);
if (!success) {
return createRateLimitResponse(headers);
}
// Your endpoint logic
const users = await prisma.user.findMany();
return NextResponse.json({ users }, { headers });
}
2. Centralized Error Handling
Why: Consistent error responses, better debugging, cleaner code.
// lib/errors.ts
export class AppError extends Error {
constructor(
public message: string,
public statusCode: number = 500,
public code?: string
) {
super(message);
this.name = "AppError";
}
}
export class ValidationError extends AppError {
constructor(message: string) {
super(message, 400, "VALIDATION_ERROR");
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 404, "NOT_FOUND");
}
}
export class UnauthorizedError extends AppError {
constructor(message = "Unauthorized") {
super(message, 401, "UNAUTHORIZED");
}
}
// Global error handler
export function handleApiError(error: unknown): NextResponse {
console.error("[API Error]", error);
if (error instanceof AppError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: error.statusCode }
);
}
// Handle Prisma errors
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2002") {
return NextResponse.json(
{ error: "A record with this value already exists" },
{ status: 409 }
);
}
}
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
Usage:
// app/api/users/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await prisma.user.findUnique({
where: { id: params.id },
});
if (!user) {
throw new NotFoundError("User");
}
return NextResponse.json({ user });
} catch (error) {
return handleApiError(error);
}
}
3. Structured Logging
Why: Debug production issues faster, track performance, monitor errors.
// lib/logger.ts
type LogLevel = "info" | "warn" | "error" | "debug";
interface LogData {
level: LogLevel;
message: string;
timestamp: string;
context?: Record<string, unknown>;
}
class Logger {
private log(level: LogLevel, message: string, context?: Record<string, unknown>) {
const logData: LogData = {
level,
message,
timestamp: new Date().toISOString(),
context,
};
const logString = JSON.stringify(logData);
switch (level) {
case "error":
console.error(logString);
break;
case "warn":
console.warn(logString);
break;
default:
console.log(logString);
}
}
info(message: string, context?: Record<string, unknown>) {
this.log("info", message, context);
}
error(message: string, error?: Error, context?: Record<string, unknown>) {
this.log("error", message, {
...context,
error: error ? {
message: error.message,
stack: error.stack,
name: error.name,
} : undefined,
});
}
// Specialized logging for API requests/responses
apiRequest(method: string, path: string, userId?: string) {
this.info("API Request", { method, path, userId });
}
apiResponse(method: string, path: string, status: number, duration: number) {
this.info("API Response", { method, path, status, duration: `${duration}ms` });
}
}
export const logger = new Logger();
Usage:
// app/api/users/route.ts
export async function GET(request: NextRequest) {
const startTime = Date.now();
try {
logger.apiRequest("GET", "/api/users", session?.user?.id);
const users = await prisma.user.findMany();
const elapsed = Date.now() - startTime;
logger.apiResponse("GET", "/api/users", 200, elapsed);
return NextResponse.json({ users });
} catch (error) {
logger.error("Failed to fetch users", error as Error);
return handleApiError(error);
}
}
4. Input Validation
Why: Prevent invalid data, reduce database errors, improve security.
// lib/validation.ts
type ValidationRule = {
type: string;
required?: boolean;
min?: number;
max?: number;
pattern?: RegExp;
};
type ValidationSchema<T> = {
[K in keyof T]: ValidationRule;
};
export function validate<T extends Record<string, unknown>>(
data: unknown,
schema: ValidationSchema<T>
): T {
const errors: string[] = [];
if (typeof data !== "object" || data === null) {
throw new ValidationError("Invalid data: expected an object");
}
const dataObj = data as Record<string, unknown>;
for (const key in schema) {
const rules = schema[key];
const value = dataObj[key];
// Required check
if (rules.required && (value === undefined || value === null)) {
errors.push(`${key} is required`);
continue;
}
if (value === undefined || value === null) continue;
// Type check
if (typeof value !== rules.type) {
errors.push(`${key} must be a ${rules.type}`);
continue;
}
// String validations
if (rules.type === "string" && typeof value === "string") {
if (rules.min && value.length < rules.min) {
errors.push(`${key} must be at least ${rules.min} characters`);
}
if (rules.max && value.length > rules.max) {
errors.push(`${key} must be at most ${rules.max} characters`);
}
if (rules.pattern && !rules.pattern.test(value)) {
errors.push(`${key} has an invalid format`);
}
}
}
if (errors.length > 0) {
throw new ValidationError(errors.join(", "));
}
return dataObj as T;
}
Usage:
// app/api/users/route.ts
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validate input
const validated = validate<{ name: string; email: string }>(body, {
name: { type: "string", required: true, min: 2, max: 100 },
email: {
type: "string",
required: true,
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
},
});
const user = await prisma.user.create({
data: validated,
});
return NextResponse.json({ user });
} catch (error) {
return handleApiError(error);
}
}
Database Optimization
1. Add Performance Indexes
Why: Indexes make queries 10-100x faster by allowing the database to find rows instantly.
Analyze Your Query Patterns
-- Find slow queries (PostgreSQL)
SELECT
query,
calls,
total_time,
mean_time,
max_time
FROM pg_stat_statements
ORDER BY mean_time DESC
LIMIT 20;
Add Indexes to Prisma Schema
// prisma/schema.prisma
model User {
id String @id @default(cuid())
email String @unique
name String?
createdAt DateTime @default(now())
posts Post[]
@@index([email]) // For lookups
@@index([createdAt]) // For sorting
}
model Post {
id String @id @default(cuid())
title String
content String
published Boolean @default(false)
authorId String
createdAt DateTime @default(now())
author User @relation(fields: [authorId], references: [id])
@@index([authorId]) // For author's posts
@@index([authorId, createdAt]) // Compound for sorted author posts
@@index([published, createdAt]) // For published posts feed
@@index([createdAt]) // For global timeline
}
model Comment {
id String @id @default(cuid())
content String
postId String
userId String
createdAt DateTime @default(now())
post Post @relation(fields: [postId], references: [id])
user User @relation(fields: [userId], references: [id])
@@index([postId]) // For post's comments
@@index([userId]) // For user's comments
@@index([postId, createdAt]) // Sorted comments per post
}
Index Selection Guidelines
| Query Pattern | Index Type | Example |
WHERE userId = ? | Single column | @@index([userId]) |
WHERE userId = ? ORDER BY createdAt | Compound | @@index([userId, createdAt]) |
WHERE published = true ORDER BY createdAt | Compound | @@index([published, createdAt]) |
WHERE email = ? (frequent) | Unique | @unique or @@index([email]) |
Run Migration
npx prisma migrate dev --name add_performance_indexes
2. Fix N+1 Query Problems
Problem: Multiple sequential queries that should be one query with joins.
// β BAD: N+1 Query Problem
const posts = await prisma.post.findMany();
// This runs a separate query for EACH post! (N queries)
for (const post of posts) {
const author = await prisma.user.findUnique({
where: { id: post.authorId }
});
console.log(author.name);
}
// β
GOOD: Single query with include
const posts = await prisma.post.findMany({
include: {
author: {
select: {
id: true,
name: true,
image: true,
}
}
}
});
3. Use Selective Field Projection
Problem: Fetching unnecessary data wastes bandwidth and memory.
// β BAD: Fetches ALL fields (including large blobs, unused data)
const user = await prisma.user.findUnique({
where: { id: userId }
});
// β
GOOD: Only fetch what you need
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
name: true,
email: true,
// Don't fetch: bio, settings, preferences, etc.
}
});
4. Parallel Queries with Promise.all
Problem: Sequential queries when they don't depend on each other.
// β BAD: Sequential queries (total time = sum of all)
const user = await prisma.user.findUnique({ where: { id } });
const posts = await prisma.post.findMany({ where: { authorId: id } });
const comments = await prisma.comment.findMany({ where: { userId: id } });
// β
GOOD: Parallel queries (total time = slowest query)
const [user, posts, comments] = await Promise.all([
prisma.user.findUnique({ where: { id } }),
prisma.post.findMany({ where: { authorId: id } }),
prisma.comment.findMany({ where: { userId: id } }),
]);
5. Connection Pooling
Why: Reuse database connections instead of creating new ones for each request.
// lib/prisma.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
// Graceful shutdown
if (process.env.NODE_ENV === "production") {
process.on("beforeExit", async () => {
await prisma.$disconnect();
});
}
export default prisma;
Environment Variables:
# .env
DATABASE_URL="postgresql://user:pass@host:5432/db?connection_limit=10&pool_timeout=20&connect_timeout=10"
6. Implement Pagination
Problem: Loading thousands of records kills performance.
// β BAD: Fetches ALL posts (could be millions!)
const posts = await prisma.post.findMany();
// β
GOOD: Paginated results
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '0');
const limit = parseInt(searchParams.get('limit') || '20');
const [posts, total] = await Promise.all([
prisma.post.findMany({
take: limit,
skip: page * limit,
orderBy: { createdAt: 'desc' },
include: {
author: {
select: { id: true, name: true, image: true }
}
}
}),
prisma.post.count()
]);
return NextResponse.json({
posts,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasMore: (page + 1) * limit < total
}
});
}
7. Use Database Aggregations
Problem: Doing calculations in JavaScript instead of in the database.
// β BAD: Fetch all records and calculate in JS
const sessions = await prisma.studySession.findMany({
where: { userId }
});
const totalMinutes = sessions.reduce((sum, s) => sum + s.duration, 0) / 60;
// β
GOOD: Let database do the aggregation
const result = await prisma.studySession.aggregate({
where: { userId },
_sum: { duration: true },
_count: true,
_avg: { duration: true },
_max: { duration: true }
});
const totalMinutes = Math.floor((result._sum.duration || 0) / 60);
Frontend Optimization
1. Code Splitting & Lazy Loading
Why: Don't load code that users might never use.
Dynamic Imports for Heavy Components
// app/components/HeavyChart.tsx - A heavy charting library
"use client";
import dynamic from 'next/dynamic';
import { Suspense } from 'react';
// β BAD: Loads immediately, even if not visible
import { Chart } from 'recharts';
export function Dashboard() {
return <Chart data={data} />;
}
// β
GOOD: Loads only when needed
const Chart = dynamic(() => import('recharts').then(mod => mod.Chart), {
loading: () => <ChartSkeleton />,
ssr: false // Don't render on server if not needed
});
export function Dashboard() {
return (
<Suspense fallback={<ChartSkeleton />}>
<Chart data={data} />
</Suspense>
);
}
Route-Based Code Splitting
// app/dashboard/page.tsx
import dynamic from 'next/dynamic';
// These components are loaded only when dashboard is accessed
const AnalyticsChart = dynamic(() => import('@/components/AnalyticsChart'));
const UserTable = dynamic(() => import('@/components/UserTable'));
const ActivityFeed = dynamic(() => import('@/components/ActivityFeed'));
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<AnalyticsChart />
<UserTable />
<ActivityFeed />
</div>
);
}
2. Prefetching & Link Optimization
Why: Load next page's data before user clicks.
"use client";
import Link from 'next/link';
import { useRouter } from 'next/navigation';
export function Navigation() {
const router = useRouter();
// β
GOOD: Prefetch on hover
const handleMouseEnter = (href: string) => {
router.prefetch(href);
};
return (
<nav>
{/* Next.js Link automatically prefetches */}
<Link
href="/dashboard"
prefetch={true} // Prefetch in viewport
>
Dashboard
</Link>
{/* Manual prefetch on hover for conditional routes */}
<button
onMouseEnter={() => handleMouseEnter('/profile')}
onClick={() => router.push('/profile')}
>
Profile
</button>
</nav>
);
}
3. Virtualization for Long Lists
Why: Render only visible items instead of thousands.
npm install react-window
"use client";
import { FixedSizeList } from 'react-window';
interface Item {
id: string;
title: string;
}
// β BAD: Renders 10,000 items (kills performance)
export function TodoList({ items }: { items: Item[] }) {
return (
<div>
{items.map(item => (
<TodoItem key={item.id} item={item} />
))}
</div>
);
}
// β
GOOD: Only renders visible items
export function VirtualizedTodoList({ items }: { items: Item[] }) {
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
<div style={style}>
<TodoItem item={items[index]} />
</div>
);
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{Row}
</FixedSizeList>
);
}
4. Optimize Re-renders with React.memo
Why: Prevent unnecessary re-renders of expensive components.
"use client";
import { memo, useState } from 'react';
interface TodoItemProps {
todo: {
id: string;
title: string;
completed: boolean;
};
onToggle: (id: string) => void;
}
// β BAD: Re-renders every time parent updates
export function TodoItem({ todo, onToggle }: TodoItemProps) {
console.log('Rendering TodoItem', todo.id);
return (
<div onClick={() => onToggle(todo.id)}>
{todo.title}
</div>
);
}
// β
GOOD: Only re-renders when props change
export const TodoItem = memo(function TodoItem({ todo, onToggle }: TodoItemProps) {
console.log('Rendering TodoItem', todo.id);
return (
<div onClick={() => onToggle(todo.id)}>
{todo.title}
</div>
);
}, (prevProps, nextProps) => {
// Custom comparison: only re-render if todo changed
return prevProps.todo.id === nextProps.todo.id &&
prevProps.todo.completed === nextProps.todo.completed &&
prevProps.todo.title === nextProps.todo.title;
});
5. Debounce & Throttle Expensive Operations
Why: Limit how often expensive functions run.
"use client";
import { useState, useCallback } from 'react';
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
export function SearchInput() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
// β BAD: API call on every keystroke
const handleChange = async (value: string) => {
const res = await fetch(`/api/search?q=${value}`);
const data = await res.json();
setResults(data);
};
// β
GOOD: API call only after user stops typing (300ms)
const debouncedSearch = useCallback(
debounce(async (value: string) => {
const res = await fetch(`/api/search?q=${value}`);
const data = await res.json();
setResults(data);
}, 300),
[]
);
return (
<input
value={query}
onChange={(e) => {
setQuery(e.target.value);
debouncedSearch(e.target.value);
}}
placeholder="Search..."
/>
);
}
6. Optimize Bundle Size
Install Bundle Analyzer:
npm install @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// your Next.js config
});
Analyze Bundle:
ANALYZE=true npm run build
Common Optimizations:
// next.config.js
module.exports = {
// Enable SWC minification (faster than Terser)
swcMinify: true,
// Remove console.logs in production
compiler: {
removeConsole: process.env.NODE_ENV === 'production',
},
// Optimize external packages
experimental: {
optimizePackageImports: ['lodash', 'date-fns', 'recharts'],
},
};
Build & Bundle Optimization
1. Tree Shaking
Why: Remove unused code from your bundle.
// β BAD: Imports entire library
import _ from 'lodash';
const result = _.groupBy(data, 'category');
// β
GOOD: Import only what you need
import groupBy from 'lodash/groupBy';
const result = groupBy(data, 'category');
// Even better with modern libraries
import { groupBy } from 'lodash-es'; // ES module version
2. Optimize Dependencies
# Analyze package sizes
npx depcheck # Find unused dependencies
npx package-size-analyzer # Check bundle impact
# Replace heavy packages with lighter alternatives
npm uninstall moment # 289 KB
npm install date-fns # 78 KB (70% smaller!)
npm uninstall lodash # 71 KB
npm install lodash-es # Tree-shakeable version
3. Static Generation (SSG) vs Server Side Rendering (SSR)
// β
BEST: Static Generation (fastest, use when possible)
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
return <Article post={post} />;
}
// β
GOOD: Server Side Rendering (use for dynamic data)
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic';
export default async function Dashboard() {
const data = await fetchUserData(); // Fresh data on every request
return <DashboardContent data={data} />;
}
// β
GOOD: Incremental Static Regeneration (best of both worlds)
// app/products/[id]/page.tsx
export const revalidate = 3600; // Revalidate every hour
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
return <ProductDetails product={product} />;
}
Image & Media Optimization
1. Next.js Image Component
Why: Automatic optimization, lazy loading, responsive images.
import Image from 'next/image';
export function ProductCard({ product }) {
return (
<div>
{/* β BAD: Regular img tag */}
<img src={product.image} alt={product.name} />
{/* β
GOOD: Next.js Image */}
<Image
src={product.image}
alt={product.name}
width={400}
height={300}
placeholder="blur"
blurDataURL={product.blurHash}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
priority={false} // Don't prioritize below-fold images
/>
</div>
);
}
2. Image Optimization Configuration
// next.config.js
module.exports = {
images: {
formats: ['image/avif', 'image/webp'], // Modern formats first
deviceSizes: [640, 750, 828, 1080, 1200, 1920], // Responsive breakpoints
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
domains: ['cdn.example.com'], // External image domains
minimumCacheTTL: 60 * 60 * 24 * 30, // Cache for 30 days
},
};
3. Lazy Load Images
"use client";
import Image from 'next/image';
import { useState, useEffect } from 'react';
export function LazyImage({ src, alt }: { src: string; alt: string }) {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ rootMargin: '100px' } // Start loading 100px before visible
);
const element = document.getElementById(`img-${src}`);
if (element) observer.observe(element);
return () => observer.disconnect();
}, [src]);
return (
<div id={`img-${src}`}>
{isVisible ? (
<Image src={src} alt={alt} width={400} height={300} />
) : (
<div className="bg-gray-200 w-full h-[300px]" />
)}
</div>
);
}
4. Video Optimization
export function VideoPlayer({ src }: { src: string }) {
return (
<video
src={src}
poster={`${src}.jpg`} // Show poster until play
preload="metadata" // Only load metadata initially
loading="lazy" // Lazy load the video
controls
playsInline
muted // Required for autoplay on mobile
/>
);
}
Caching Strategies
1. Redis Caching Layer
// lib/cache.ts
import { Redis } from "@upstash/redis";
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
export const cache = {
async get<T>(key: string): Promise<T | null> {
try {
return await redis.get<T>(key);
} catch (error) {
console.error("Cache get error:", error);
return null;
}
},
async set<T>(key: string, value: T, ttl: number = 300): Promise<void> {
try {
await redis.set(key, value, { ex: ttl });
} catch (error) {
console.error("Cache set error:", error);
}
},
async del(key: string): Promise<void> {
try {
await redis.del(key);
} catch (error) {
console.error("Cache del error:", error);
}
},
async invalidatePattern(pattern: string): Promise<void> {
try {
const keys = await redis.keys(pattern);
if (keys.length > 0) {
await redis.del(...keys);
}
} catch (error) {
console.error("Cache invalidate error:", error);
}
},
};
// Predefined cache keys
export const cacheKeys = {
userProfile: (userId: string) => `profile:${userId}`,
userStats: (userId: string) => `stats:${userId}`,
posts: (page: number) => `posts:page:${page}`,
postDetail: (postId: string) => `post:${postId}`,
};
Usage:
// app/api/users/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const cacheKey = cacheKeys.userProfile(params.id);
// Try cache first
const cached = await cache.get<User>(cacheKey);
if (cached) {
return NextResponse.json(
{ user: cached },
{ headers: { 'X-Cache': 'HIT' } }
);
}
// Cache miss - fetch from database
const user = await prisma.user.findUnique({
where: { id: params.id },
});
// Store in cache (5 minutes)
await cache.set(cacheKey, user, 300);
return NextResponse.json(
{ user },
{ headers: { 'X-Cache': 'MISS' } }
);
}
2. Cache Invalidation Strategy
// lib/cache.ts
export const cacheInvalidation = {
// Invalidate user-related caches
async invalidateUser(userId: string) {
await Promise.all([
cache.del(cacheKeys.userProfile(userId)),
cache.del(cacheKeys.userStats(userId)),
cache.invalidatePattern(`posts:user:${userId}:*`),
]);
},
// Invalidate post caches
async invalidatePost(postId: string) {
await Promise.all([
cache.del(cacheKeys.postDetail(postId)),
cache.invalidatePattern('posts:page:*'), // Invalidate all post lists
]);
},
};
// Usage after mutations
export async function POST(request: NextRequest) {
const user = await prisma.user.create({ data: newUserData });
// Invalidate related caches
await cacheInvalidation.invalidateUser(user.id);
return NextResponse.json({ user });
}
3. HTTP Caching Headers
// app/api/posts/route.ts
export async function GET(request: NextRequest) {
const posts = await getPosts();
return NextResponse.json(
{ posts },
{
headers: {
// Cache in browser for 5 minutes
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
// Add ETag for conditional requests
'ETag': generateETag(posts),
},
}
);
}
4. Service Worker Caching (PWA)
// public/sw.js
const CACHE_NAME = 'app-v1';
const urlsToCache = [
'/',
'/styles/main.css',
'/scripts/main.js',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(urlsToCache))
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => response || fetch(event.request))
);
});
API & Network Optimization
1. Response Compression
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// Enable compression
response.headers.set('Content-Encoding', 'gzip');
return response;
}
2. GraphQL Data Loading (Avoid Over-fetching)
npm install @apollo/client graphql
// β BAD: REST - Multiple requests
const user = await fetch('/api/users/123');
const posts = await fetch('/api/users/123/posts');
const comments = await fetch('/api/users/123/comments');
// β
GOOD: GraphQL - Single request, exact data needed
const { data } = await client.query({
query: gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
posts(limit: 10) {
id
title
}
comments(limit: 5) {
id
content
}
}
}
`,
variables: { id: '123' },
});
3. Implement Request Batching
// lib/batch-fetcher.ts
class BatchFetcher {
private queue: Array<{ id: string; resolve: Function; reject: Function }> = [];
private timer: NodeJS.Timeout | null = null;
fetch(id: string): Promise<any> {
return new Promise((resolve, reject) => {
this.queue.push({ id, resolve, reject });
if (!this.timer) {
this.timer = setTimeout(() => this.flush(), 10); // Batch window: 10ms
}
});
}
private async flush() {
const currentQueue = [...this.queue];
this.queue = [];
this.timer = null;
const ids = currentQueue.map(item => item.id);
try {
// Single database query for all IDs
const results = await prisma.user.findMany({
where: { id: { in: ids } },
});
const resultsMap = new Map(results.map(r => [r.id, r]));
currentQueue.forEach(({ id, resolve }) => {
resolve(resultsMap.get(id));
});
} catch (error) {
currentQueue.forEach(({ reject }) => reject(error));
}
}
}
export const userBatcher = new BatchFetcher();
// Usage
const [user1, user2, user3] = await Promise.all([
userBatcher.fetch('1'),
userBatcher.fetch('2'),
userBatcher.fetch('3'),
]);
// Results in single database query instead of 3!
Deployment & CDN
1. Vercel Edge Functions
// app/api/hello/route.ts
export const runtime = 'edge'; // Deploy to edge locations
export async function GET(request: Request) {
return new Response('Hello from the Edge!', {
headers: {
'content-type': 'text/plain',
'x-edge-location': request.headers.get('x-vercel-ip-city') || 'unknown',
},
});
}
2. CDN Configuration
// next.config.js
module.exports = {
// Enable static file compression
compress: true,
// Configure headers for CDN
async headers() {
return [
{
source: '/static/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
{
source: '/images/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
];
},
};
3. Asset Optimization for CDN
# Compress assets before deployment
npm install --save-dev imagemin imagemin-webp
# Create optimization script
// scripts/optimize-images.js
const imagemin = require('imagemin');
const imageminWebp = require('imagemin-webp');
(async () => {
await imagemin(['public/images/*.{jpg,png}'], {
destination: 'public/images/optimized',
plugins: [
imageminWebp({ quality: 75 })
]
});
console.log('Images optimized!');
})();
Monitoring & Analytics
1. Performance Monitoring
// lib/monitoring.ts
export class PerformanceMonitor {
static recordMetric(name: string, value: number, unit: string = 'ms') {
if (typeof window !== 'undefined' && 'PerformanceObserver' in window) {
performance.mark(`${name}-${value}`);
// Send to analytics
fetch('/api/metrics', {
method: 'POST',
body: JSON.stringify({ name, value, unit }),
}).catch(console.error);
}
}
static measurePageLoad() {
if (typeof window !== 'undefined') {
window.addEventListener('load', () => {
const perfData = window.performance.timing;
const loadTime = perfData.loadEventEnd - perfData.navigationStart;
this.recordMetric('page-load', loadTime);
});
}
}
static measureAPICall(endpoint: string, duration: number) {
this.recordMetric(`api-${endpoint}`, duration);
}
}
2. Error Tracking (Sentry Integration)
npm install @sentry/nextjs
// sentry.client.config.ts
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1.0,
beforeSend(event, hint) {
// Filter out non-critical errors
if (event.level === 'info') {
return null;
}
return event;
},
});
3. Real User Monitoring (RUM)
// app/providers/analytics.tsx
"use client";
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
export function AnalyticsProvider({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
useEffect(() => {
// Track Core Web Vitals
if ('web-vital' in window) {
const { getCLS, getFID, getLCP, getFCP, getTTFB } = window['web-vital'];
getCLS(console.log);
getFID(console.log);
getLCP(console.log);
getFCP(console.log);
getTTFB(console.log);
}
}, []);
useEffect(() => {
// Track page views
fetch('/api/analytics/pageview', {
method: 'POST',
body: JSON.stringify({ path: pathname }),
}).catch(console.error);
}, [pathname]);
return <>{children}</>;
}
Performance Benchmarks
API Response Times on dsbored121.vercel.app
| Endpoint | Before | After | Improvement |
| GET /api/users | 450ms | 120ms | 73% faster β‘ |
| GET /api/posts | 380ms | 95ms | 75% faster β‘ |
| GET /api/stats | 1290ms | 180ms | 65% faster β‘ |
| POST /api/users | 290ms | 85ms | 71% faster β‘ |
Page Load Times
| Page | Before | After | Improvement |
| Homepage | 2.2s | 1.4s | 25% faster β‘ |
| Dashboard | 4.1s | 1.8s | 56% faster β‘ |
| Profile | 2.1s | 1.2s | 30% faster β‘ |
Lighthouse Scores
| Metric | Before | After |
| Performance | 62 | 94 |
| Accessibility | 88 | 95 |
| Best Practices | 79 | 92 |
| SEO | 91 | 100 |
Common Pitfalls & Solutions
β Pitfall #1: Over-Caching
Problem: Caching data that changes frequently leads to stale data.
Solution: Use appropriate TTL values:
User profile: 5 minutes
Static content: 1 hour
Frequently updated data: 30 seconds
Real-time data: Don't cache
β Pitfall #2: Cache Stampede
Problem: When cache expires, multiple requests hit database simultaneously.
Solution: Use stale-while-revalidate pattern:
export async function GET(request: NextRequest) {
const cacheKey = 'expensive-data';
const cached = await cache.get(cacheKey);
if (cached) {
// Return cached data immediately
const response = NextResponse.json(cached);
// Revalidate in background if stale (>80% of TTL)
const age = await cache.getAge(cacheKey);
if (age > 240) { // 4 minutes of 5 minute TTL
revalidateInBackground(cacheKey);
}
return response;
}
// Cache miss - fetch fresh data
const data = await fetchExpensiveData();
await cache.set(cacheKey, data, 300);
return NextResponse.json(data);
}
β Pitfall #3: Missing Database Indexes
Problem: Slow queries because database scans entire table.
Solution: Run EXPLAIN ANALYZE and add indexes:
-- Find missing indexes (PostgreSQL)
SELECT schemaname, tablename, attname, n_distinct, correlation
FROM pg_stats
WHERE schemaname = 'public'
AND n_distinct > 100
AND correlation < 0.1;
β Pitfall #4: Large Bundle Size
Problem: Importing entire libraries when you only need one function.
Solution: Use tree-shakeable imports:
// β BAD: 500 KB bundle
import * as _ from 'lodash';
// β
GOOD: 10 KB bundle
import { groupBy } from 'lodash-es';
Advanced Techniques
1. Incremental Static Regeneration (ISR)
Use case: E-commerce product pages that update occasionally.
// app/products/[id]/page.tsx
export const revalidate = 3600; // Revalidate every hour
export async function generateStaticParams() {
const products = await db.product.findMany({ take: 1000 });
return products.map(p => ({ id: p.id }));
}
export default async function ProductPage({
params
}: {
params: { id: string }
}) {
const product = await db.product.findUnique({
where: { id: params.id }
});
return <ProductDetails product={product} />;
}
Result:
First visit: Generated statically at build time
After 1 hour: Next visitor triggers regeneration
Everyone else: Sees cached version (instant)
2. Parallel Data Fetching
// app/dashboard/page.tsx
export default async function Dashboard() {
// β BAD: Sequential (4 seconds total)
const user = await getUser(); // 1s
const stats = await getStats(); // 1s
const posts = await getPosts(); // 1s
const comments = await getComments(); // 1s
// β
GOOD: Parallel (1 second total - slowest query)
const [user, stats, posts, comments] = await Promise.all([
getUser(),
getStats(),
getPosts(),
getComments(),
]);
return <DashboardContent {...{ user, stats, posts, comments }} />;
}
3. React Server Components + Streaming
// app/dashboard/page.tsx
import { Suspense } from 'react';
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* Fast component loads immediately */}
<UserProfile />
{/* Slow component streams in when ready */}
<Suspense fallback={<ChartSkeleton />}>
<ExpensiveChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<DataTable />
</Suspense>
</div>
);
}
// This component can take 2-3 seconds to load
async function ExpensiveChart() {
const data = await fetchExpensiveData();
return <Chart data={data} />;
}
Result: Users see header and profile immediately, charts load progressively.
4. Edge Middleware for A/B Testing
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// A/B test: 50% see new design
const bucket = Math.random() > 0.5 ? 'A' : 'B';
const response = NextResponse.next();
response.cookies.set('ab-test-bucket', bucket);
// Add custom header for analytics
response.headers.set('X-AB-Test', bucket);
return response;
}
5. Database Query Result Caching (Prisma)
// lib/prisma.ts
import { PrismaClient } from '@prisma/client';
import { cache } from './cache';
const prismaClientSingleton = () => {
const client = new PrismaClient();
// Add caching layer to Prisma queries
return client.$extends({
query: {
user: {
async findUnique({ args, query }) {
const cacheKey = `user:${args.where.id}`;
const cached = await cache.get(cacheKey);
if (cached) return cached;
const result = await query(args);
await cache.set(cacheKey, result, 300);
return result;
},
},
},
});
};
type PrismaClientSingleton = ReturnType<typeof prismaClientSingleton>;
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClientSingleton | undefined;
};
export const prisma = globalForPrisma.prisma ?? prismaClientSingleton();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
Production Deployment Checklist
Pre-Deployment
[ ] Run
npm run buildand check for warnings[ ] Test in production mode locally:
npm start[ ] Run Lighthouse audit on production build
[ ] Check bundle size:
ANALYZE=true npm run build[ ] Verify all environment variables are set
[ ] Test database connection pooling limits
[ ] Confirm Redis/cache is accessible
[ ] Set up error monitoring (Sentry, Datadog)
Post-Deployment
[ ] Monitor error rates for first 24 hours
[ ] Check API response times in production
[ ] Verify cache hit rates
[ ] Monitor database query performance
[ ] Set up uptime monitoring (Vercel, UptimeRobot)
[ ] Configure alerts for critical errors
[ ] Review logs for any warnings
Ongoing Maintenance
[ ] Weekly Lighthouse audits
[ ] Monthly dependency updates
[ ] Quarterly performance review
[ ] Monitor cache effectiveness
[ ] Review and optimize slow queries
[ ] Update rate limits based on usage patterns
Tools & Resources
Performance Testing
Lighthouse - Built into Chrome DevTools
WebPageTest - https://www.webpagetest.org/
GTmetrix - https://gtmetrix.com/
PageSpeed Insights - https://pagespeed.web.dev/
Bundle Analysis
Next.js Bundle Analyzer -
@next/bundle-analyzerWebpack Bundle Analyzer - Built into Next.js
Source Map Explorer -
npm install -g source-map-explorer
Database
Prisma Studio - Visual database editor
pgAdmin - PostgreSQL administration
Explain Visualizer - https://explain.dalibo.com/
Monitoring
Vercel Analytics - Built-in for Vercel deployments
Sentry - Error tracking and performance monitoring
Datadog - Full-stack monitoring
New Relic - Application performance monitoring
CDN & Hosting
Vercel - Optimal for Next.js apps
Cloudflare - CDN and DDoS protection
AWS CloudFront - AWS CDN service
Conclusion
Performance optimization is not a one-time taskβit's an ongoing process. Start with the critical items, measure your improvements, and iterate.
Key Takeaways
Measure first - Don't optimize blindly
Start with backend - API performance impacts everything
Database matters most - Proper indexes = 10-100x faster
Cache aggressively - But invalidate correctly
Optimize images - Usually the largest assets
Monitor continuously - Catch regressions early
Expected Results
Following this guide should give you:
β‘ 60-70% faster API responses
β‘ 50-60% faster page loads
β‘ 90+ Lighthouse score
β‘ Better user experience
β‘ Lower hosting costs




