Next.js 15 Features
A comprehensive deep dive into Next.js 15 features, including Turbopack stability, Server Actions improvements, Partial Prerendering, and performance optimizations.
Next.js 15: The New Era of React Framework Performance
Next.js 15 represents a major leap forward in the React ecosystem, bringing production-ready features that developers have been eagerly anticipating. With Turbopack reaching stable status, enhanced Server Actions, and the revolutionary Partial Prerendering (PPR), this release transforms how we build modern web applications.
In this comprehensive guide, we'll explore every major feature, provide practical code examples, and show you how to migrate your existing Next.js applications to take full advantage of these improvements.
What's New in Next.js 15
Stability Milestones
Next.js 15 marks several critical stability milestones:
| Feature | Status | Impact |
|---|---|---|
| Turbopack | Stable | Up to 700x faster HMR, 10x faster builds |
| Server Actions | Stable | Simplified data mutations without API routes |
| Partial Prerendering | Stable | Hybrid static + dynamic rendering |
| App Router | Stable | Full feature parity with Pages Router |
Turbopack: The Future of Next.js Build Tool
Turbopack, written in Rust, replaces Webpack for development and production builds. After years of refinement, it's now stable and production-ready.
Performance Improvements
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// Turbopack is now default in Next.js 15
// No configuration needed - it just works!
}
module.exports = nextConfig
Benchmarks:
- Local Development HMR: Up to 700x faster than Webpack
- Production Builds: Up to 10x faster
- Startup Time: 4x faster
- Memory Usage: 30% lower
Turbopack Advanced Configuration
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// Turbopack-specific optimizations
experimental: {
turbo: {
// Memory limit for Turbopack (default: 8192 MB)
memoryLimit: 16384,
// Resolve aliases
resolveAlias: {
'@': './src',
'@components': './src/components',
},
// Turbopack rules
rules: {
'*.svg': {
loaders: ['@svgr/webpack'],
as: '*.js',
},
},
},
},
// Optimize CSS handling
optimizeCss: true,
}
module.exports = nextConfig
Turbopack + SWC: The Perfect Pair
Next.js 15 uses SWC (Speedy Web Compiler) for minification and transformation, complementing Turbopack's bundling capabilities.
// next.config.js - SWC optimizations
/** @type {import('next').NextConfig} */
const nextConfig = {
swcMinify: true, // Enabled by default
compiler: {
// Remove console.log in production
removeConsole: process.env.NODE_ENV === 'production',
// React Fast Refresh for HMR
reactFastRefresh: true,
// Remove PropTypes in production
removePropTypes: true,
},
}
module.exports = nextConfig
Server Actions: Server-Side Mutations Simplified
Server Actions, introduced in Next.js 13.4, are now fully stable and provide an elegant way to handle data mutations without creating API routes.
Basic Server Action
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
interface FormData {
title: string
content: string
author: string
}
export async function createPost(formData: FormData) {
// 1. Validate input
if (!formData.title || !formData.content) {
throw new Error('Title and content are required')
}
// 2. Perform server-side operation
const post = await db.post.create({
data: {
title: formData.title,
content: formData.content,
author: formData.author,
},
})
// 3. Revalidate cached data
revalidatePath('/posts')
revalidatePath(`/posts/${post.id}`)
// 4. Optional: Redirect or return data
redirect(`/posts/${post.id}`)
// Or return data to client:
// return { success: true, postId: post.id }
}
Using Server Actions in Forms
// app/posts/new/page.tsx
import { createPost } from '@/app/actions'
export default function NewPostPage() {
return (
<form action={createPost} className="space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium">
Title
</label>
<input
id="title"
name="title"
type="text"
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
/>
</div>
<div>
<label htmlFor="content" className="block text-sm font-medium">
Content
</label>
<textarea
id="content"
name="content"
required
rows={5}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
/>
</div>
<button
type="submit"
className="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
Create Post
</button>
</form>
)
}
Server Actions with Client Components
// components/PostForm.tsx
'use client'
import { useState } from 'react'
import { createPost } from '@/app/actions'
export default function PostForm() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(formData: FormData) {
setIsSubmitting(true)
setError(null)
try {
await createPost(formData as any)
// Server action handles redirect
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create post')
setIsSubmitting(false)
}
}
return (
<form action={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-4 text-red-700">
{error}
</div>
)}
{/* Form fields here */}
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? 'Creating...' : 'Create Post'}
</button>
</form>
)
}
Advanced Server Actions Patterns
// app/actions/advanced.ts
'use server'
import { revalidateTag } from 'next/cache'
import { headers } from 'next/headers'
// Action with error handling
export async function updateUser(formData: {
id: string
name: string
email: string
}) {
try {
const user = await db.user.update({
where: { id: formData.id },
data: {
name: formData.name,
email: formData.email,
},
})
revalidateTag('users')
return { success: true, user }
} catch (error) {
if (error instanceof Error) {
return { success: false, error: error.message }
}
return { success: false, error: 'Unknown error' }
}
}
// Action with authentication
export async function deletePost(postId: string) {
// Get session from headers
const headersList = await headers()
const sessionToken = headersList.get('x-session-token')
if (!sessionToken) {
throw new Error('Unauthorized')
}
// Validate session
const session = await validateSession(sessionToken)
if (!session) {
throw new Error('Invalid session')
}
// Check permissions
const post = await db.post.findUnique({ where: { id: postId } })
if (!post || post.authorId !== session.userId) {
throw new Error('Forbidden')
}
// Delete post
await db.post.delete({ where: { id: postId } })
// Revalidate
revalidatePath('/posts')
revalidatePath(`/posts/${postId}`)
revalidateTag('posts')
return { success: true }
}
// Streaming action
export async function processLargeDataset(datasetId: string) {
const dataset = await db.dataset.findUnique({
where: { id: datasetId },
})
if (!dataset) {
throw new Error('Dataset not found')
}
// Process in chunks
const CHUNK_SIZE = 1000
let processed = 0
while (processed < dataset.totalItems) {
const chunk = await db.item.findMany({
where: { datasetId },
skip: processed,
take: CHUNK_SIZE,
})
await Promise.all(
chunk.map(async (item) => {
await processItem(item)
})
)
processed += chunk.length
// Emit progress (if using streaming)
emitProgress({
datasetId,
processed,
total: dataset.totalItems,
})
}
revalidateTag(`dataset-${datasetId}`)
return { success: true, processed }
}
Server Action Validation with Zod
// lib/validations.ts
import { z } from 'zod'
export const createPostSchema = z.object({
title: z.string().min(1, 'Title is required').max(200, 'Title too long'),
content: z.string().min(1, 'Content is required').max(10000, 'Content too long'),
authorId: z.string().uuid('Invalid author ID'),
tags: z.array(z.string()).default([]),
published: z.boolean().default(false),
})
export type CreatePostInput = z.infer<typeof createPostSchema>
// app/actions/validated.ts
'use server'
import { createPostSchema } from '@/lib/validations'
import { revalidatePath } from 'next/cache'
export async function createPostWithValidation(data: unknown) {
// Validate input
const result = createPostSchema.safeParse(data)
if (!result.success) {
// Return validation errors
return {
success: false,
errors: result.error.flatten().fieldErrors,
}
}
// Create post with validated data
const post = await db.post.create({
data: result.data,
})
revalidatePath('/posts')
return {
success: true,
post,
}
}
Partial Prerendering (PPR): The Best of Both Worlds
Partial Prerendering is a groundbreaking feature that allows you to combine static and dynamic rendering in the same page. This means you can serve a fast static shell while hydrating dynamic content in the background.
Understanding PPR
PPR works by:
- Rendering the static shell at build time (instant delivery)
- Streaming dynamic content on demand (fresh data)
- Progressive enhancement (user can interact immediately)
Enabling Partial Prerendering
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// Enable Partial Prerendering
experimental: {
ppr: 'incremental', // or 'true' for PPR everywhere
},
}
module.exports = nextConfig
PPR in Action
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { PostList } from '@/components/PostList'
import { UserProfile } from '@/components/UserProfile'
import { TrendingPosts } from '@/components/TrendingPosts'
// Static shell - rendered at build time
export const dynamic = 'force-static' // This overrides PPR for specific pages
// OR use PPR for this page
export const dynamic = 'auto' // PPR will apply
export default function DashboardPage() {
return (
<div className="container mx-auto p-6">
{/* Static content - instant load */}
<header>
<h1 className="text-3xl font-bold">Dashboard</h1>
<p className="text-gray-600">Welcome back!</p>
</header>
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Left column: Static sidebar */}
<aside className="lg:col-span-1">
<nav className="space-y-2">
<a href="/dashboard" className="block rounded-md bg-blue-600 px-4 py-2 text-white">
Overview
</a>
<a href="/dashboard/posts" className="block rounded-md px-4 py-2 hover:bg-gray-100">
Posts
</a>
<a href="/dashboard/analytics" className="block rounded-md px-4 py-2 hover:bg-gray-100">
Analytics
</a>
</nav>
</aside>
{/* Right column: Dynamic content */}
<main className="lg:col-span-2 space-y-6">
{/* Streaming content with Suspense boundaries */}
<Suspense fallback={<div className="animate-pulse h-64 bg-gray-200 rounded" />}>
<UserProfile />
</Suspense>
<Suspense fallback={<div className="animate-pulse h-64 bg-gray-200 rounded" />}>
<TrendingPosts />
</Suspense>
<Suspense fallback={<div className="animate-pulse h-64 bg-gray-200 rounded" />}>
<PostList />
</Suspense>
</main>
</div>
</div>
)
}
PPR with Route Segments
// app/products/[slug]/page.tsx
import { ProductDetails } from '@/components/ProductDetails'
import { ProductReviews } from '@/components/ProductReviews'
import { RelatedProducts } from '@/components/RelatedProducts'
export default function ProductPage({ params }: { params: { slug: string } }) {
return (
<div>
{/* Static shell: product basics cached */}
<ProductDetails slug={params.slug} />
{/* Dynamic content: streamed on demand */}
<Suspense fallback={<div>Loading reviews...</div>}>
<ProductReviews slug={params.slug} />
</Suspense>
<Suspense fallback={<div>Loading related products...</div>}>
<RelatedProducts slug={params.slug} />
</Suspense>
</div>
)
}
PPR Performance Comparison
// Without PPR (dynamic)
// - Initial HTML: 2.3s
// - First Contentful Paint: 2.5s
// - Time to Interactive: 3.1s
// With PPR (static + dynamic streaming)
// - Initial HTML: 0.1s (static shell)
// - First Contentful Paint: 0.2s
// - Time to Interactive: 0.3s
// - Dynamic content: Streams in over 0.5s
Enhanced App Router Features
Next.js 15 brings several enhancements to the App Router, improving developer experience and performance.
Improved Caching Controls
// app/posts/page.tsx
import { Suspense } from 'react'
// Revalidate after 60 seconds
export const revalidate = 60
// OR revalidate on-demand using tags
export async function generateMetadata() {
return {
title: 'Blog Posts',
}
}
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
// Cache options
next: {
tags: ['posts'], // Tag for revalidation
revalidate: 3600, // Revalidate every hour
},
})
if (!res.ok) throw new Error('Failed to fetch posts')
return res.json()
}
export default async function PostsPage() {
const posts = await getPosts()
return (
<div>
<h1>Blog Posts</h1>
<ul>
{posts.map((post: any) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
)
}
Optimistic Updates with useOptimistic
// app/todos/page.tsx
'use client'
import { useOptimistic, useTransition } from 'react'
import { addTodo, deleteTodo } from '@/app/actions'
interface Todo {
id: string
text: string
completed: boolean
}
export default function TodosPage({ initialTodos }: { initialTodos: Todo[] }) {
const [isPending, startTransition] = useTransition()
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
initialTodos,
(state, action: { type: 'ADD' | 'DELETE'; todo?: Todo; id?: string }) => {
if (action.type === 'ADD' && action.todo) {
return [...state, { ...action.todo, id: 'temp-id' }]
}
if (action.type === 'DELETE' && action.id) {
return state.filter((todo) => todo.id !== action.id)
}
return state
}
)
function handleAddTodo(formData: FormData) {
const text = formData.get('text') as string
startTransition(() => {
addOptimisticTodo({
type: 'ADD',
todo: { id: 'temp', text, completed: false },
})
addTodo(text)
})
}
function handleDeleteTodo(id: string) {
startTransition(() => {
addOptimisticTodo({ type: 'DELETE', id })
deleteTodo(id)
})
}
return (
<div>
<form action={handleAddTodo} className="space-y-2">
<input
name="text"
type="text"
placeholder="Add a todo..."
className="rounded-md border px-3 py-2"
/>
<button
type="submit"
disabled={isPending}
className="rounded-md bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
Add Todo
</button>
</form>
<ul className="mt-4 space-y-2">
{optimisticTodos.map((todo) => (
<li key={todo.id} className="flex items-center justify-between">
<span>{todo.text}</span>
<button
onClick={() => handleDeleteTodo(todo.id)}
className="text-red-600 hover:text-red-700"
>
Delete
</button>
</li>
))}
</ul>
</div>
)
}
Improved Error Handling
// app/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex h-screen flex-col items-center justify-center">
<h2 className="text-2xl font-bold">Something went wrong!</h2>
<p className="text-gray-600">{error.message}</p>
<button
onClick={reset}
className="mt-4 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
Try again
</button>
</div>
)
}
// app/not-found.tsx
import Link from 'next/link'
export default function NotFound() {
return (
<div className="flex h-screen flex-col items-center justify-center">
<h1 className="text-4xl font-bold">404 - Page Not Found</h1>
<p className="mt-2 text-gray-600">
The page you're looking for doesn't exist.
</p>
<Link
href="/"
className="mt-4 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
Go Home
</Link>
</div>
</div>
)
TypeScript Improvements
Next.js 15 includes enhanced TypeScript support with better type inference and developer experience.
Improved Route Parameter Types
// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation'
// Better type inference for params
export default async function PostPage({
params,
searchParams,
}: {
params: { slug: string }
searchParams: { [key: string]: string | string[] | undefined }
}) {
// params.slug is properly typed as string
const post = await getPost(params.slug)
if (!post) {
notFound()
}
return <div>{post.title}</div>
}
Typed Server Actions
// app/actions.ts
'use server'
import { z } from 'zod'
const createPostSchema = z.object({
title: z.string(),
content: z.string(),
})
export async function createPost(input: z.infer<typeof createPostSchema>) {
// input is properly typed!
const { title, content } = input
await db.post.create({ data: { title, content } })
}
Performance Optimizations
Built-in Image Optimization
import Image from 'next/image'
export default function GalleryPage() {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{images.map((image) => (
<div key={image.id}>
<Image
src={image.url}
alt={image.alt}
width={400}
height={300}
loading="lazy"
className="rounded-md"
// New in Next.js 15: priority loading for above-the-fold images
priority={false}
// New: automatic blur placeholder
placeholder="blur"
blurDataURL={image.blurDataURL}
/>
</div>
))}
</div>
)
}
Font Optimization
// app/layout.tsx
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap', // Better performance
})
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={`${inter.variable} font-sans`}>{children}</body>
</html>
)
}
Script Optimization
import Script from 'next/script'
export default function Analytics() {
return (
<>
{/* Load Google Analytics */}
<Script
src="https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID"
strategy="afterInteractive"
/>
<Script id="google-analytics" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'GA_MEASUREMENT_ID');
`}
</Script>
{/* Load analytics after page load */}
<Script src="/analytics.js" strategy="lazyOnload" />
</>
)
}
Migration Guide: Upgrading from Next.js 14
Step 1: Update Dependencies
npm install next@15 react@18 react-dom@18
npm install -D @types/react@18 @types/react-dom@18
Step 2: Update next.config.js
// next.config.js (Next.js 14)
/** @type {import('next').NextConfig} */
const nextConfig = {
// Old config
webpack: (config) => {
// Custom webpack config
return config
},
}
module.exports = nextConfig
// next.config.js (Next.js 15)
/** @type {import('next').NextConfig} */
const nextConfig = {
// Turbopack is now default for dev
// No webpack config needed for most cases
// If you still need webpack, wrap it in a conditional
webpack: (config, { isServer }) => {
// Custom webpack config (only runs when not using Turbopack)
return config
},
// Enable experimental features
experimental: {
ppr: 'incremental', // Enable Partial Prerendering
},
}
module.exports = nextConfig
Step 3: Update Server Actions
// Before (Next.js 13/14)
export async function createPost(formData: FormData) {
// Implementation
}
// After (Next.js 15 - recommended)
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
// Add revalidation
const post = await db.post.create({ data: { title: '...' } })
revalidatePath('/posts')
return post
}
Step 4: Update API Routes to Server Actions
// pages/api/posts.ts (Old API route)
import type { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method === 'POST') {
const { title, content } = req.body
const post = await db.post.create({ data: { title, content } })
res.status(201).json(post)
}
}
// app/actions/posts.ts (New Server Action)
'use server'
export async function createPost(formData: { title: string; content: string }) {
const post = await db.post.create({ data: formData })
revalidatePath('/posts')
return post
}
Real-World Examples
E-Commerce Product Page with PPR
// app/products/[id]/page.tsx
import { ProductHeader } from '@/components/products/ProductHeader'
import { ProductGallery } from '@/components/products/ProductGallery'
import { ProductReviews } from '@/components/products/ProductReviews'
import { RelatedProducts } from '@/components/products/RelatedProducts'
import { AddToCartButton } from '@/components/products/AddToCartButton'
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<div className="container mx-auto px-4 py-8">
{/* Static shell - fast initial load */}
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
<div>
<ProductHeader id={params.id} />
<ProductGallery id={params.id} />
</div>
<div className="space-y-6">
<AddToCartButton productId={params.id} />
{/* Dynamic content - streamed */}
<Suspense fallback={<div className="animate-pulse h-64 bg-gray-200 rounded" />}>
<ProductReviews id={params.id} />
</Suspense>
<Suspense fallback={<div className="animate-pulse h-64 bg-gray-200 rounded" />}>
<RelatedProducts id={params.id} />
</Suspense>
</div>
</div>
</div>
)
}
Real-time Dashboard with Server Actions
// app/dashboard/analytics/page.tsx
import { Suspense } from 'react'
import { AnalyticsChart } from '@/components/analytics/AnalyticsChart'
import { MetricsCards } from '@/components/analytics/MetricsCards'
import { RecentActivity } from '@/components/analytics/RecentActivity'
export default function AnalyticsPage() {
return (
<div className="space-y-6">
{/* Static header */}
<header>
<h1 className="text-3xl font-bold">Analytics Dashboard</h1>
<p className="text-gray-600">Real-time performance metrics</p>
</header>
{/* Streaming analytics data */}
<Suspense fallback={<div className="animate-pulse h-32 bg-gray-200 rounded" />}>
<MetricsCards />
</Suspense>
<Suspense fallback={<div className="animate-pulse h-64 bg-gray-200 rounded" />}>
<AnalyticsChart />
</Suspense>
<Suspense fallback={<div className="animate-pulse h-48 bg-gray-200 rounded" />}>
<RecentActivity />
</Suspense>
</div>
)
}
// app/actions/analytics.ts
'use server'
import { revalidateTag } from 'next/cache'
// Update analytics in real-time
export async function updateAnalytics(event: {
type: 'view' | 'click' | 'purchase'
userId: string
page?: string
}) {
// Track event
await db.analytics.create({
data: {
type: event.type,
userId: event.userId,
page: event.page,
timestamp: new Date(),
},
})
// Revalidate analytics dashboard
revalidateTag('analytics')
return { success: true }
}
Blog with Incremental Static Regeneration
// app/blog/page.tsx
export const revalidate = 3600 // Revalidate every hour
export async function generateMetadata() {
const posts = await getPosts()
return {
title: `Blog - ${posts.length} Articles`,
description: 'Latest articles and tutorials',
}
}
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: {
tags: ['posts'],
revalidate: 3600,
},
})
if (!res.ok) throw new Error('Failed to fetch posts')
return res.json()
}
export default async function BlogPage() {
const posts = await getPosts()
return (
<div>
<h1 className="text-3xl font-bold">Blog</h1>
<ul className="mt-6 space-y-4">
{posts.map((post: any) => (
<li key={post.id}>
<h2 className="text-xl font-semibold">{post.title}</h2>
<p className="text-gray-600">{post.excerpt}</p>
</li>
))}
</ul>
</div>
)
}
// app/actions/blog.ts
'use server'
import { revalidateTag } from 'next/cache'
// Trigger revalidation when a new post is published
export async function publishPost(postId: string) {
await db.post.update({
where: { id: postId },
data: { published: true },
})
revalidateTag('posts')
return { success: true }
}
Best Practices for Next.js 15
1. Use Server Actions for Data Mutations
// ✅ Good: Server Action
export async function createUser(formData: FormData) {
const user = await db.user.create({ data: { ...formData } })
revalidatePath('/users')
return user
}
// ❌ Bad: API route (unnecessary with Server Actions)
// app/api/users/route.ts
export async function POST(req: NextRequest) {
const data = await req.json()
const user = await db.user.create({ data })
return NextResponse.json(user, { status: 201 })
}
2. Leverage Partial Prerendering
// ✅ Good: Use Suspense for dynamic content
export default function Page() {
return (
<div>
<StaticHeader />
<Suspense fallback={<Loading />}>
<DynamicContent />
</Suspense>
</div>
)
}
// ❌ Bad: Entire page dynamic
export const dynamic = 'force-dynamic'
export default async function Page() {
const data = await fetch('https://api.example.com/data')
return <div>{/* ... */}</div>
}
3. Optimize Images Properly
// ✅ Good: Next.js Image with proper sizing
<Image
src="/hero.jpg"
alt="Hero section"
width={1200}
height={600}
priority // Above the fold
placeholder="blur"
/>
// ❌ Bad: Regular img tag
<img src="/hero.jpg" alt="Hero section" />
4. Use Appropriate Caching Strategies
// ✅ Good: Appropriate revalidation
const staticData = await fetch('/api/static', {
next: { revalidate: 3600 }, // 1 hour
})
const realtimeData = await fetch('/api/realtime', {
next: { revalidate: 0 }, // No caching
})
const tagBasedData = await fetch('/api/data', {
next: { tags: ['data'] }, // Revalidate on demand
})
// ❌ Bad: Always no cache
const data = await fetch('/api/data', { cache: 'no-store' })
5. Use Turbopack for Development
# ✅ Good: Use Turbopack (default in Next.js 15)
next dev
# ❌ Bad: Force Webpack
next dev --turbo=false
Performance Tips
1. Minimize Client-Side JavaScript
// ✅ Good: Server Components by default
export default async function Page() {
const data = await fetchData()
return <div>{/* Server-rendered content */}</div>
}
// ❌ Bad: Unnecessary Client Component
'use client'
export default function Page() {
const [data, setData] = useState(null)
useEffect(() => {
fetchData().then(setData)
}, [])
return <div>{/* Client-side rendering */}</div>
}
2. Use Streaming for Large Data
export default function Page() {
return (
<Suspense fallback={<Loading />}>
<LargeDataList />
</Suspense>
)
}
3. Optimize Font Loading
// ✅ Good: Use next/font
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
// ❌ Bad: External font loading
<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet" />
Troubleshooting Common Issues
Issue 1: Turbopack Build Failures
# Clear Turbopack cache
rm -rf .next
# Force rebuild
next build --turbo
Issue 2: Server Actions Not Working
// Make sure file has 'use server' at the top
'use server'
export async function myAction() {
// ...
}
Issue 3: PPR Not Working
// Ensure PPR is enabled in next.config.js
experimental: {
ppr: 'incremental',
}
// Use Suspense boundaries
<Suspense fallback={<Loading />}>
<DynamicContent />
</Suspense>
Conclusion
Next.js 15 represents a significant evolution in web development, combining the performance of static generation with the flexibility of dynamic rendering. With Turbopack's stability, Server Actions maturity, and Partial Prerendering's revolutionary approach, developers now have the tools to build incredibly fast, scalable applications.
Key Takeaways
- Turbopack is production-ready - Experience dramatically faster development and builds
- Server Actions simplify data mutations - Eliminate the need for API routes
- Partial Prerendering offers the best of both worlds - Static shell with dynamic content streaming
- Enhanced TypeScript support - Better type inference and developer experience
- Performance optimizations built-in - From image optimization to intelligent caching
Next Steps
- Upgrade your Next.js application to version 15
- Enable Turbopack for development
- Migrate API routes to Server Actions
- Implement Partial Prerendering for suitable pages
- Optimize images and fonts
- Set up proper caching strategies
- Monitor performance and iterate
The future of web development is here, and Next.js 15 is leading the charge. Embrace these features, optimize your applications, and deliver exceptional user experiences at scale.
Ready to supercharge your Next.js applications? Start upgrading today and experience the performance improvements firsthand.