Next.js 15 in Production: Performance Tips and Gotchas
Ship Next.js 15 to production with confidence. Server components, caching pitfalls, bundle optimization, ISR, and real-world performance techniques.
Next.js 15: What Changed for Production
Next.js 15 introduced significant changes to caching, server components, and the App Router. If you are upgrading from 14 or deploying a new project, there are critical differences that affect production performance. At TechSaaS, our company website runs on Next.js 15 with static export — here is what we learned.
A well-structured configuration file is the foundation of reproducible infrastructure.
Gotcha 1: Caching Is No Longer Aggressive by Default
In Next.js 14, fetch requests were cached by default. In Next.js 15, they are not. This is the biggest change and catches many teams off guard.
// Next.js 14: cached by default
const data = await fetch('https://api.example.com/products');
// Next.js 15: NOT cached by default
const data = await fetch('https://api.example.com/products');
// Next.js 15: explicitly cache
const data = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 } // Cache for 1 hour
});
// Or cache indefinitely
const data = await fetch('https://api.example.com/products', {
cache: 'force-cache'
});
Action: Audit every fetch call and add explicit caching where appropriate. Without this, every page load hits your API.
Gotcha 2: Dynamic vs Static Rendering
Next.js decides at build time whether a page is static or dynamic. Anything that reads headers, cookies, or search params makes the entire route dynamic.
// This page is DYNAMIC (reads cookies)
export default async function Dashboard() {
const session = await cookies(); // Forces dynamic rendering
// ... rest of page
}
// This page is STATIC (no dynamic functions)
export default async function Blog() {
const posts = await fetch('https://cms.example.com/posts', {
next: { revalidate: 3600 }
});
// ... render posts
}
Get more insights on Tutorials
Join 2,000+ engineers who get our weekly deep-dives. No spam, unsubscribe anytime.
Check which pages are static vs dynamic after building:
next build
# Look for the output:
# ○ (Static) /about
# ● (SSG) /blog
# λ (Dynamic) /dashboard
# ƒ (Dynamic) /api/webhook
Tip 1: Use Server Components by Default
Server components render on the server, send zero JavaScript to the client, and can directly access databases and APIs. Use them for everything that does not need interactivity.
// app/products/page.tsx (Server Component — default)
import { db } from '@/lib/db';
export default async function ProductsPage() {
// Direct database access — no API layer needed
const products = await db.query('SELECT * FROM products WHERE active = true');
return (
<div className="grid grid-cols-3 gap-4">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
// components/ProductCard.tsx (Server Component)
function ProductCard({ product }: { product: Product }) {
return (
<div className="border rounded-lg p-4">
<h3>{product.name}</h3>
<p className="text-gray-600">{product.description}</p>
<span className="font-bold">${product.price}</span>
<AddToCartButton productId={product.id} />
</div>
);
}
// components/AddToCartButton.tsx (Client Component — needs onClick)
'use client';
export function AddToCartButton({ productId }: { productId }) {
return (
<button => addToCart(productId)} className="btn-primary">
Add to Cart
</button>
);
}
Rule: Only add 'use client' when you need useState, useEffect, onClick, or browser APIs.
Performance optimization funnel: each layer of optimization compounds to dramatically reduce response times.
Tip 2: Optimize Images Aggressively
import Image from 'next/image';
// Good: Responsive with proper sizing
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={630}
priority // LCP image — preload it
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"
quality={80} // Default is 75, 80 is a good balance
/>
// For static export (output: 'export'), use unoptimized or a loader
// next.config.js
module.exports = {
output: 'export',
images: {
unoptimized: true, // For static hosting (nginx, S3)
// Or use a CDN image loader
// loader: 'custom',
// loaderFile: './image-loader.ts',
},
};
Tip 3: Bundle Size Analysis
# Install analyzer
npm install @next/bundle-analyzer
# next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({ /* your config */ });
# Run analysis
ANALYZE=true next build
Common bundle size wins:
- Replace
moment.js(300KB) withdate-fns(tree-shakeable) or nativeIntl - Replace
lodashwithlodash-esor native methods - Dynamic import heavy components:
const Chart = dynamic(() => import('./Chart')) - Move heavy client libs behind
dynamic(..., { ssr: false })
Tip 4: ISR (Incremental Static Regeneration)
For pages that change periodically but do not need real-time data:
// app/blog/[slug]/page.tsx
export const revalidate = 3600; // Revalidate every hour
export async function generateStaticParams() {
const posts = await fetch('https://cms.example.com/posts')
.then(r => r.json());
return posts.map((post: any) => ({
slug: post.slug,
}));
}
export default async function BlogPost({ params }: { params: { slug } }) {
const post = await fetch(`https://cms.example.com/posts/${params.slug}`, {
next: { revalidate: 3600 }
}).then(r => r.json());
return <article>{/* render post */}</article>;
}
Tip 5: Streaming and Suspense
Stream long-running content instead of blocking the entire page:
Free Resource
Free Cloud Architecture Checklist
A 47-point checklist covering security, scalability, cost optimization, and disaster recovery for production cloud environments.
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* This loads instantly */}
<UserGreeting />
{/* This streams in when ready */}
<Suspense fallback={<ChartSkeleton />}>
<AnalyticsChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders />
</Suspense>
</div>
);
}
async function AnalyticsChart() {
// This takes 2-3 seconds
const data = await fetchAnalytics();
return <Chart data={data} />;
}
The page shell loads immediately while heavy components stream in. Users see content faster.
Tip 6: Metadata and SEO
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: {
template: '%s | TechSaaS',
default: 'TechSaaS - Infrastructure for Modern Teams',
},
description: 'Self-hosted infrastructure...',
openGraph: {
type: 'website',
siteName: 'TechSaaS',
images: [{ url: '/og-image.png', width: 1200, height: 630 }],
},
robots: { index: true, follow: true },
};
// Per-page metadata
export async function generateMetadata({ params }): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
type: 'article',
publishedTime: post.date,
},
};
}
Docker Compose brings up your entire stack with a single command.
Production Deployment Checklist
- Run
next buildand check for static/dynamic page classification - Verify no unnecessary dynamic rendering
- Add explicit caching to all fetch calls
- Analyze bundle size, eliminate large dependencies
- Set
priorityon above-the-fold images - Use Suspense for slow data fetches
- Add proper metadata for SEO
- Enable compression in your reverse proxy (gzip/brotli)
- Set proper Cache-Control headers for static assets
At TechSaaS, our Next.js 15 site achieves a Lighthouse score of 95+ on all metrics running on a simple nginx:alpine container behind Traefik.
Related Service
Cloud Solutions
Let our experts help you build the right technology strategy for your business.
Need help with tutorials?
TechSaaS provides expert consulting and managed services for cloud infrastructure, DevOps, and AI/ML operations.
We Will Build You a Demo Site — For Free
Like it? Pay us. Do not like it? Walk away, zero complaints. You will spend way less than hiring developers or any agency.
No spam. No contracts. Just a free demo.