Introduction
Next.js 15 brings together three of the most anticipated features in the React ecosystem: Turbopack reaching stability for development, a revamped caching system with explicit opt-in, and Partial Pre-Rendering (PPR) improvements that make the static-plus-dynamic rendering model production-ready. These features collectively address the performance trilemma of web development: build speed, runtime performance, and developer experience.
Turbopack, written in Rust, replaces Webpack for development with 4-27x speed improvements. The new caching model makes fetch requests uncached by default, eliminating the "stale data" confusion that affected Next.js 14 applications. PPR enables serving a static shell from the edge while streaming dynamic content, combining the performance of static generation with the personalization of server-side rendering.
Understanding Turbopack: Core Concepts
Turbopack is the successor to Webpack, built from scratch in Rust by the creators of Webpack (Tobias Koppers) and the Next.js team at Vercel. It uses an incremental computation engine that recomputes only what changed, rather than rebuilding the entire dependency graph.
The Incremental Computation Model
Turbopack's core innovation is its function-level caching model. When a file changes, Turbopack identifies which functions are affected, invalidates only those functions, and recomputes their outputs. This is fundamentally different from Webpack's module-level caching, which invalidates entire modules and their dependents.
// Turbopack processes this file
export function expensiveComputation(data: Data) {
// This function's output is cached
return processLargeDataset(data)
}
export function cheapTransform(value: string) {
// This function is also cached independently
return value.trim().toLowerCase()
}
// When this file changes, Turbopack only recomputes
// functions that actually use the changed codeTurbopack vs Webpack Architecture
| Aspect | Webpack | Turbopack |
|---|---|---|
| Language | JavaScript | Rust |
| Caching | Module-level | Function-level |
| Incremental | Partial | Full |
| Memory | Heap-allocated | Arena-allocated |
| Bundling | Eager (all at once) | Lazy (on demand) |
| Tree Shaking | Post-bundle | During bundling |
Development Server Performance
# Enable Turbopack in development
next dev --turbopack
# Turbopack uses a persistent disk cache
# First run: 2.4s startup
# Second run: 0.8s startup (cache hit)Real-world benchmarks from production applications:
| Application Size | Webpack Dev | Turbopack Dev | Improvement |
|---|---|---|---|
| 100 pages | 8s startup | 1.2s startup | 6.7x |
| 500 pages | 34s startup | 4.1s startup | 8.3x |
| 1000 pages | 78s startup | 8.5s startup | 9.2x |
| HMR (any size) | 300-900ms | 15-45ms | 20x |
Understanding the New Caching Model
Next.js 15's caching changes are the most significant behavioral shift since the introduction of the App Router. The core change: fetch requests are no longer cached by default.
Why the Change?
In Next.js 14, developers frequently encountered stale data because fetch was cached by default. This led to:
- Users seeing outdated content after database updates
- Developers adding
cache: 'no-store'to every fetch call - Confusion about when data would refresh
- Production bugs caused by unexpected caching
New Caching Behavior
// Next.js 15: fetch is NOT cached by default
const data = await fetch('https://api.example.com/data')
// This makes a fresh request every time
// Opt in to caching explicitly
const data = await fetch('https://api.example.com/data', {
cache: 'force-cache'
})
// Time-based revalidation
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 }
})
// Tag-based revalidation
const data = await fetch('https://api.example.com/data', {
next: { tags: ['products'] }
})Caching Decision Matrix
| Data Type | Caching Strategy | Example |
|---|---|---|
| Static content | cache: 'force-cache' | Blog posts, documentation |
| Semi-dynamic | next: { revalidate: N } | Product listings, news |
| Real-time | cache: 'no-store' | Chat messages, live data |
| User-specific | cache: 'no-store' | Dashboard, settings |
| External API | next: { revalidate: N } | Weather, stock prices |
Route Handler Caching
GET Route Handlers are also no longer cached by default:
// app/api/products/route.ts - NOT cached by default
export async function GET() {
const products = await db.product.findMany()
return Response.json(products)
}
// Opt in to caching
export const dynamic = 'force-static'
export async function GET() {
const products = await db.product.findMany()
return Response.json(products)
}
// Or use revalidation
export const revalidate = 3600
export async function GET() {
const products = await db.product.findMany()
return Response.json(products)
}Partial Pre-Rendering (PPR)
PPR combines static and dynamic rendering in a single page. The static shell is pre-rendered at build time and served from the edge. Dynamic holes are filled at request time by streaming Server Component output.
How PPR Works Under the Hood
- Build time: Next.js renders the page, identifying static and dynamic boundaries
- Static parts: Rendered to HTML and stored at the edge
- Dynamic parts: Replaced with inline
<script>tags that reference Server Component endpoints - Request time: The static HTML is served immediately; dynamic parts stream in as their Server Components render
// app/shop/page.tsx - PPR example
import { Suspense } from 'react'
import { StaticHeader } from '@/components/header' // Static
import { StaticFooter } from '@/components/footer' // Static
export default function ShopPage() {
return (
<div>
{/* Static: pre-rendered at build time */}
<StaticHeader />
<main>
{/* Static: product catalog */}
<ProductCatalog />
{/* Dynamic: personalized recommendations */}
<Suspense fallback={<RecommendationsSkeleton />}>
<PersonalizedRecommendations /> {/* Uses cookies() → dynamic */}
</Suspense>
{/* Dynamic: cart count */}
<Suspense fallback={<CartCountSkeleton />}>
<CartCount /> {/* Uses cookies() → dynamic */}
</Suspense>
</main>
{/* Static: pre-rendered at build time */}
<StaticFooter />
</div>
)
}
// Dynamic because it reads cookies for user identification
async function PersonalizedRecommendations() {
const userId = await getCurrentUserId() // Uses cookies()
const recommendations = await getRecommendations(userId)
return <RecommendationGrid items={recommendations} />
}
// Dynamic because it reads cookies for cart session
async function CartCount() {
const session = await getSession() // Uses cookies()
const count = await getCartCount(session.cartId)
return <CartBadge count={count} />
}PPR Performance Characteristics
| Metric | Full SSR | Full Static | PPR |
|---|---|---|---|
| TTFB | 200-800ms | 10-50ms | 10-50ms |
| FCP | 300-1000ms | 50-200ms | 50-200ms |
| LCP | 500-1500ms | 100-400ms | 200-600ms |
| Personalization | Full | None | Full |
| Edge Cacheable | No | Yes | Shell only |
Step-by-Step Implementation
Setting up a Next.js 15 project with Turbopack, caching, and PPR:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
ppr: true, // Enable Partial Pre-Rendering
},
}
module.exports = nextConfig// package.json
{
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start"
}
}Implementing a PPR-optimized e-commerce product page:
// app/products/[id]/page.tsx
import { Suspense } from 'react'
import { db } from '@/lib/db'
import { notFound } from 'next/navigation'
// Static: can be pre-rendered
export async function generateStaticParams() {
const products = await db.product.findMany({
where: { featured: true },
select: { id: true },
})
return products.map(p => ({ id: p.id }))
}
export default async function ProductPage({ params }: { params: { id: string } }) {
return (
<article className="max-w-4xl mx-auto">
{/* Static: product details rendered at build time */}
<Suspense fallback={<ProductDetailSkeleton />}>
<ProductDetails id={params.id} />
</Suspense>
{/* Dynamic: personalized content */}
<div className="grid grid-cols-2 gap-8 mt-8">
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={params.id} />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<PersonalizedRecommendations productId={params.id} />
</Suspense>
</div>
</article>
)
}
async function ProductDetails({ id }: { id: string }) {
const product = await db.product.findUnique({
where: { id },
include: { images: true, specifications: true },
})
if (!product) notFound()
return <ProductDetailLayout product={product} />
}Real-World Use Cases
Use Case 1: News Website with PPR
A news website with 100,000+ articles uses PPR to serve article pages. The article body (static) is pre-rendered and cached at the edge. The comment section, trending articles sidebar, and personalized newsletter signup (all dynamic) stream in after the static shell. First Contentful Paint improved from 1.8 seconds (full SSR) to 300ms (PPR), while maintaining real-time comments and personalized content.
Use Case 2: SaaS Dashboard with Turbopack
A SaaS development team migrated from Webpack to Turbopack, reducing their development startup time from 45 seconds to 5 seconds. The 20x HMR improvement (from 1.2 seconds to 60ms) transformed their development workflow—developers could now see changes instantly, enabling rapid iteration on complex dashboard components.
Use Case 3: E-Commerce with Explicit Caching
An e-commerce platform upgraded to Next.js 15's explicit caching model. Previously, they relied on default caching which caused occasional stale pricing. Now, product pages use revalidate: 60 for prices (refreshed every minute), force-cache for product descriptions (rarely change), and no-store for inventory counts (real-time). The explicit strategy eliminated pricing bugs and reduced API calls by 40% through better cache utilization.
Best Practices for Production
-
Use Turbopack for development only: Turbopack is stable for
next devbut production builds still use Webpack. Don't use--turbopackin CI/CD yet. -
Audit all fetch calls after upgrading: The caching default change means previously cached requests are now uncached. Add explicit cache directives to maintain expected behavior.
-
Enable PPR incrementally: Start with a few high-traffic pages, measure the impact, then roll out to more pages.
-
Place Suspense boundaries strategically: Each Suspense boundary is a streaming chunk. Too many boundaries create too many HTTP chunks; too few delay content delivery.
-
Use generateStaticParams for known dynamic routes: Pre-render popular pages at build time while allowing on-demand rendering for long-tail pages.
-
Monitor cache hit rates: Track how often the static PPR shell is served from cache vs. regenerated. Low hit rates indicate too many dynamic components.
-
Test with slow networks: Use Chrome DevTools throttling to verify that PPR fallbacks provide meaningful loading states on slow connections.
-
Document caching decisions: Add comments explaining why each fetch call uses a specific caching strategy. Future developers will thank you.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Turbopack not supporting Webpack plugin | Build error | Check Turbopack compatibility list; use native alternatives |
| Fetch requests now uncached | Increased API/database load | Add explicit cache: 'force-cache' or revalidate where needed |
| PPR not activating for a page | Full SSR instead of PPR | Ensure no dynamic APIs are used outside Suspense boundaries |
| Too many Suspense boundaries | Excessive HTTP chunks | Group related content under fewer, larger Suspense boundaries |
| generateStaticParams too aggressive | Long build times | Limit to top-N pages; use dynamicParams: true for the rest |
| Caching user-specific data | Data leakage between users | Use cache: 'no-store' for user-specific requests |
Performance Optimization
// Optimized fetch with explicit caching
export default async function ProductPage({ params }: { params: { id: string } }) {
// Parallel fetches with different caching strategies
const [product, reviews, recommendations] = await Promise.all([
// Product: cache for 1 hour
fetch(`/api/products/${params.id}`, {
next: { revalidate: 3600, tags: [`product-${params.id}`] }
}).then(r => r.json()),
// Reviews: cache for 5 minutes
fetch(`/api/products/${params.id}/reviews`, {
next: { revalidate: 300 }
}).then(r => r.json()),
// Recommendations: personalized, not cached
fetch(`/api/recommendations?product=${params.id}`, {
cache: 'no-store'
}).then(r => r.json()),
])
return (
<div>
<ProductView product={product} />
<ReviewsList reviews={reviews} />
<Recommendations items={recommendations} />
</div>
)
}Comparison with Alternatives
| Feature | Next.js 15 PPR | Astro Islands | Remix | SvelteKit |
|---|---|---|---|---|
| Static Shell | Pre-rendered | Pre-rendered | Not available | Pre-rendered |
| Dynamic Streaming | Suspense boundaries | Component-level | Loader streaming | Load functions |
| Edge Delivery | Shell from edge | Static from edge | Full from edge | Static from edge |
| Personalization | Streaming | Hydration | SSR | SSR |
| Build Tool | Turbopack | Vite | Vite | Vite |
| Caching | Explicit opt-in | Manual | HTTP cache | Manual |
Advanced Patterns
Combining PPR with ISR
// Static shell with ISR + dynamic streaming
export const revalidate = 3600 // Revalidate shell every hour
export default async function NewsPage() {
return (
<div>
{/* Static: article list, revalidated hourly */}
<ArticleList />
{/* Dynamic: personalized "for you" section */}
<Suspense fallback={<ForYouSkeleton />}>
<PersonalizedFeed /> {/* Uses auth → dynamic */}
</Suspense>
{/* Dynamic: trending topics */}
<Suspense fallback={<TrendingSkeleton />}>
<TrendingTopics /> {/* Real-time data → dynamic */}
</Suspense>
</div>
)
}Testing Strategies
// Testing caching behavior
import { NextRequest } from 'next/server'
import { GET } from './route'
describe('API Route caching', () => {
it('returns fresh data by default (no cache)', async () => {
const request = new NextRequest('http://localhost:3000/api/products')
const response = await GET(request)
expect(response.headers.get('cache-control')).not.toContain('max-age')
})
it('returns cached data when force-static is set', async () => {
// Test with dynamic = 'force-static'
const response = await GET()
expect(response.headers.get('cache-control')).toContain('max-age')
})
})
// Testing PPR rendering
describe('Product page PPR', () => {
it('renders static shell immediately', async () => {
const html = await renderToString(<ProductPage params={{ id: '1' }} />)
// Static content is in the HTML
expect(html).toContain('product-name')
expect(html).toContain('product-description')
// Dynamic content has Suspense fallback
expect(html).toContain('reviews-skeleton')
})
})PPR Cache Invalidation and Revalidation
Cache invalidation in PPR requires understanding the two-layer caching model. The static shell is cached at the CDN level and invalidated through Next.js revalidation mechanisms like revalidatePath and revalidateTag. The dynamic chunks are cached at the edge runtime level with shorter TTLs appropriate for user-specific content. When you invalidate a page, only the static shell is regenerated while dynamic chunks continue to serve personalized content.
On-demand revalidation with revalidatePath triggers regeneration of the static shell for a specific route. Combine this with tag-based revalidation using revalidateTag to invalidate multiple pages that share data dependencies. For example, updating a product should revalidate both the product page and any category pages that display the product. Tag-based revalidation ensures all dependent pages update simultaneously without requiring explicit knowledge of which pages use which data.
The interaction between PPR and React's cache function creates opportunities for request-level deduplication within dynamic chunks. Multiple components within the same request can call the same cached function, and React deduplicates the calls automatically. This eliminates the waterfall problem where sequential data fetches slow down dynamic rendering. Structure your data fetching to call cache at the component level rather than hoisting fetches to the page level, allowing each component to declare its own data dependencies.
Future Outlook
Turbopack will become the production bundler in a future Next.js version, eliminating the Webpack dependency entirely. PPR will become the default rendering strategy for all App Router pages. The caching model will continue to evolve toward more explicit, predictable behavior with better developer tooling for cache inspection and debugging.
Turbopack vs Vite Comparison
Turbopack and Vite both offer fast development builds but use different approaches. Vite uses native ES modules and esbuild for dependency pre-bundling, providing instant server start and fast hot module replacement. Turbopack uses Rust-based incremental computation with a dependency graph that tracks at the function level. For large applications, Turbopack may provide faster rebuilds due to its more granular caching, while Vite offers broader ecosystem compatibility and framework-agnostic support. Evaluate both tools with your specific application size and configuration to determine which provides better performance for your use case.
Turbopack Configuration Options
Configure Turbopack behavior through the next.config.js file using the experimental.turbo key. Customize module resolution aliases, define global variables, and configure loaders for custom file types. Turbopack automatically applies loaders for built-in file types (JavaScript, TypeScript, CSS, images) but you can add custom loaders for files like .mdx, .svg, or .glsl. Use the Turbopack build mode (next build --turbopack) for production builds and compare output sizes with the default Webpack builder.
Conclusion
Next.js 15's Turbopack, caching changes, and PPR improvements represent a significant leap in web development performance and developer experience. Turbopack delivers 4-27x faster development builds through Rust-based incremental computation. The explicit caching model eliminates the most common source of production bugs in Next.js 14. PPR enables serving a static shell from the edge while streaming dynamic content.
Key takeaways:
- Turbopack is stable for development—enable it for dramatically faster iteration
fetchrequests are no longer cached by default—add explicit cache directives- PPR combines static shell rendering with dynamic streaming for optimal performance
- Place
Suspenseboundaries strategically to control streaming granularity - Use
generateStaticParamsto pre-render popular pages while allowing on-demand rendering
For deeper exploration, consult the Turbopack documentation, Next.js caching guide, and PPR RFC.