MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Next.js 15 Stable: What's New and Migration Guide

Next.js 15 stable release: breaking changes, new features, and migration from v14.

Next.jsReactFrontendMigration

By MinhVo

Introduction

Next.js 15 stable, released in September 2024, represents a significant evolution of the React framework with a focus on developer experience, performance, and alignment with the React 19 release. This version introduces React 19 support, async Request APIs, improved caching with fetch caching no longer the default, Turbopack stable for development, and a new after API for post-response processing. While maintaining backward compatibility through codemods and gradual migration paths, Next.js 15 makes several breaking changes that teams need to understand before upgrading.

The most impactful change is the shift in caching defaults. In Next.js 14, fetch requests were cached by default, which caused confusion when developers expected fresh data. Next.js 15 flips this: fetch requests are no longer cached by default, and caching must be explicitly opted into. This aligns with the principle of least surprise and reduces the "why is my data stale?" debugging sessions that plagued Next.js 14 applications.

Next.js 15 Release

Understanding the Key Changes: Core Concepts

React 19 Support

Next.js 15 supports React 19, which brings several improvements:

// React 19 features available in Next.js 15
 
// 1. Actions - async transitions for mutations
'use client'
import { useActionState } from 'react'
 
function Form({ action }) {
  const [state, formAction, isPending] = useActionState(action, { error: null })
  return (
    <form action={formAction}>
      <input name="email" type="email" />
      <button disabled={isPending}>
        {isPending ? 'Submitting...' : 'Submit'}
      </button>
      {state.error && <p>{state.error}</p>}
    </form>
  )
}
 
// 2. use() hook for reading resources in render
import { use } from 'react'
 
function UserProfile({ userPromise }) {
  const user = use(userPromise)  // Suspends until resolved
  return <div>{user.name}</div>
}
 
// 3. Ref as prop (no more forwardRef)
function Input({ ref, ...props }) {
  return <input ref={ref} {...props} />
}
 
// 4. Cleanup functions for refs
function Canvas({ ref }) {
  return (
    <canvas
      ref={(canvas) => {
        // Setup
        const ctx = canvas.getContext('2d')
        // Return cleanup function
        return () => ctx.clearRect(0, 0, canvas.width, canvas.height)
      }}
    />
  )
}

Async Request APIs

In Next.js 15, cookies(), headers(), params, and searchParams are now async. This is a breaking change that affects all Server Components using these APIs:

// Next.js 14 (synchronous - OLD)
import { cookies, headers } from 'next/headers'
 
export default function Page({ params, searchParams }) {
  const cookieStore = cookies()
  const headerStore = headers()
  const { id } = params
  const { q } = searchParams
  // ...
}
 
// Next.js 15 (async - NEW)
import { cookies, headers } from 'next/headers'
 
export default async function Page({ params, searchParams }) {
  const cookieStore = await cookies()
  const headerStore = await headers()
  const { id } = await params
  const { q } = await searchParams
  // ...
}

This change enables streaming and better performance by not blocking the render on request data resolution.

Async Request APIs

Caching Changes

The most significant behavioral change in Next.js 15 is the default caching behavior:

// Next.js 14: fetch is CACHED by default
const data = await fetch('https://api.example.com/data')
// This was cached! You had to opt out with cache: 'no-store'
 
// Next.js 15: fetch is NOT cached by default
const data = await fetch('https://api.example.com/data')
// This is NOT cached. You must opt in with cache: 'force-cache'
 
// Explicit caching in Next.js 15
const data = await fetch('https://api.example.com/data', {
  cache: 'force-cache'  // Cache this request
})
 
const data = await fetch('https://api.example.com/data', {
  next: { revalidate: 3600 }  // Revalidate every hour
})

For GET Route Handlers, caching is also no longer the default:

// Next.js 14: GET handlers were cached by default
export async function GET() {
  const data = await db.query('...')
  return Response.json(data)
}
 
// Next.js 15: GET handlers are NOT cached by default
export async function GET() {
  const data = await db.query('...')
  return Response.json(data)
}
 
// Opt in to caching
export const dynamic = 'force-static'
 
export async function GET() {
  const data = await db.query('...')
  return Response.json(data)
}

Turbopack Stable for Development

Turbopack, the Rust-based bundler, is now stable for next dev:

# Use Turbopack for development (default in Next.js 15)
next dev --turbopack
 
# Or in package.json
{
  "scripts": {
    "dev": "next dev --turbopack"
  }
}

Turbopack provides:

  • 76.7% faster cold startup compared to Webpack
  • 96.3% faster Fast Refresh with code changes
  • 45.8% faster initial compilation

Turbopack Performance Benchmarks

OperationWebpackTurbopackImprovement
Cold startup (1000 modules)10.2s2.4s4.3x
Fast Refresh (1 file change)870ms32ms27x
Initial page load3.1s1.2s2.6x
Navigation (cached route)340ms45ms7.6x

The after API

The after API allows you to schedule work to run after the response has been sent to the client:

// app/api/webhook/route.ts
import { after } from 'next/server'
import { sendEmail } from '@/lib/email'
import { logAnalytics } from '@/lib/analytics'
 
export async function POST(request: Request) {
  const payload = await request.json()
 
  // Process the webhook immediately
  await processWebhook(payload)
 
  // Schedule work after the response is sent
  after(async () => {
    // This runs AFTER the response is sent to the client
    await sendEmail({
      to: 'admin@example.com',
      subject: 'Webhook received',
      body: `Webhook payload: ${JSON.stringify(payload)}`
    })
    await logAnalytics('webhook_received', payload)
  })
 
  return Response.json({ status: 'ok' })
}

The after API is useful for:

  • Sending analytics events without blocking the response
  • Logging without impacting response time
  • Triggering background jobs
  • Sending notification emails
  • Updating search indexes

Step-by-Step Migration from Next.js 14

Step 1: Run the Codemod

Next.js provides automated codemods for the most common breaking changes:

# Run the upgrade codemod
npx @next/codemod@latest upgrade
 
# This handles:
# - Updating async Request APIs (cookies, headers, params, searchParams)
# - Updating React 19 changes (forwardRef removal, useActionState)
# - Updating package versions

Step 2: Update Async APIs Manually

The codemod handles most cases, but some require manual review:

// Before: Synchronous access
export default function Page({ params }) {
  const { slug } = params
  return <div>{slug}</div>
}
 
// After: Async access
export default async function Page({ params }) {
  const { slug } = await params
  return <div>{slug}</div>
}
 
// Before: Component using cookies
function getUser() {
  const cookieStore = cookies()
  const token = cookieStore.get('token')
  return verifyToken(token)
}
 
// After: Must await cookies
async function getUser() {
  const cookieStore = await cookies()
  const token = cookieStore.get('token')
  return verifyToken(token)
}

Step 3: Update Caching Strategy

Review all fetch calls and decide which should be cached:

// Audit your fetch calls
// Previously cached by default, now explicitly opt-in
 
// Public data that rarely changes → cache
const products = await fetch('https://api.example.com/products', {
  cache: 'force-cache'
})
 
// User-specific data → don't cache
const user = await fetch('https://api.example.com/user', {
  cache: 'no-store'
})
 
// Data that changes periodically → revalidate
const posts = await fetch('https://api.example.com/posts', {
  next: { revalidate: 3600 }
})

Step 4: Update React 19 Changes

// Replace forwardRef with ref prop
// Before
const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
  return <input ref={ref} {...props} />
})
 
// After
function Input({ ref, ...props }: InputProps & { ref: React.Ref<HTMLInputElement> }) {
  return <input ref={ref} {...props} />
}
 
// Replace useFormState with useActionState
// Before
import { useFormState } from 'react-dom'
const [state, formAction] = useFormState(action, initialState)
 
// After
import { useActionState } from 'react'
const [state, formAction, isPending] = useActionState(action, initialState)

Real-World Use Cases

Use Case 1: SaaS Platform Upgrade

A SaaS platform with 200+ pages migrated from Next.js 14 to 15 over 3 weeks. The biggest challenge was the async params change—every page using dynamic routes needed updating. The codemod handled 85% of cases automatically. The caching changes required reviewing 50+ fetch calls to determine the correct caching strategy. The team used this as an opportunity to add explicit caching policies where they'd previously relied on defaults.

Use Case 2: E-Commerce Performance Optimization

An e-commerce platform upgraded to Next.js 15 specifically for Turbopack. Their Webpack-based development environment had grown to 45-second cold starts with 2,000+ modules. After enabling Turbopack, cold starts dropped to 4 seconds and Fast Refresh from 2.3 seconds to 40ms. The team's iteration speed improved dramatically—developers reported being able to "see changes as fast as they could type."

Use Case 3: Content Platform with Background Jobs

A content platform used the after API to decouple response delivery from background processing. When an article is published, the response returns immediately while after handles sending subscriber notifications, updating the search index, and generating social media cards. This reduced the publish response time from 1.2 seconds to 120ms.

Best Practices for Production

  1. Run the codemod first: The automated codemod handles 80-90% of breaking changes. Run it before manual migration to reduce effort.

  2. Audit caching behavior carefully: The default caching change affects every fetch call. Review each one and add explicit cache directives.

  3. Test async API changes thoroughly: The async params, searchParams, cookies(), and headers() changes are pervasive. Use TypeScript to catch missing await keywords.

  4. Enable Turbopack for development: It's stable and provides dramatic speed improvements. Keep Webpack for production builds until Turbopack production support is stable.

  5. Use after for non-critical work: Move analytics, logging, and notifications to after to improve response times.

  6. Update React 19 patterns incrementally: Replace forwardRef and useFormState as you touch files—no need for a mass rewrite.

  7. Monitor performance after upgrade: The caching changes may increase database load if previously cached requests are now uncached. Monitor your database and API metrics.

  8. Use React 19's use() hook for streaming: Replace useEffect + state patterns with use() for data that should suspend.

Common Pitfalls and Solutions

PitfallImpactSolution
Missing await on params/searchParamsRuntime error or undefined valuesRun the codemod and add await to all request API calls
Fetch calls no longer cachedIncreased API/database loadAdd cache: 'force-cache' or next: { revalidate } to appropriate calls
cookies() called outside async contextBuild errorEnsure the function calling cookies() is async and properly awaited
forwardRef not updatedTypeScript errorsReplace with ref-as-prop pattern
useFormState import errorBuild failureReplace with useActionState from react
Turbopack not supporting custom Webpack configBuild errorsKeep Webpack for production; use Turbopack only for development

Performance Optimization

// Using React 19's use() hook for streaming data
import { Suspense, use } from 'react'
 
async function getUser() {
  const res = await fetch('https://api.example.com/user')
  return res.json()
}
 
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise)  // Suspends until resolved
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  )
}
 
export default function Dashboard() {
  const userPromise = getUser()  // Start fetching immediately
 
  return (
    <Suspense fallback={<div>Loading profile...</div>}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  )
}

Comparison with Alternatives

FeatureNext.js 15Remix v2Astro 4Nuxt 3
React 19 SupportFullPartialIslandsN/A (Vue)
Caching DefaultOpt-inHTTP cacheStaticHybrid
Dev BundlerTurbopack (Rust)ViteViteVite
Async Request APIsYesLoader functionsN/AAsync setup
Background Jobsafter APIDeferred dataN/AServer middleware
Migration ComplexityMediumLowLowLow

Advanced Patterns

Combining after with Server Actions

'use server'
 
import { after } from 'next/server'
import { revalidatePath } from 'next/cache'
 
export async function publishArticle(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string
 
  // Critical: create the article
  const article = await db.article.create({
    data: { title, content, published: true }
  })
 
  // Non-critical: run after response
  after(async () => {
    await sendSubscriberNotifications(article)
    await updateSearchIndex(article)
    await generateSocialCards(article)
    await logAnalytics('article_published', { id: article.id })
  })
 
  revalidatePath('/articles')
  revalidatePath(`/articles/${article.slug}`)
}

Testing Strategies

// Testing async params and searchParams
import { render, screen } from '@testing-library/react'
 
describe('ProductPage', () => {
  it('renders with async params', async () => {
    // Mock async params
    const params = Promise.resolve({ id: '123' })
    const searchParams = Promise.resolve({ tab: 'reviews' })
 
    const Page = await ProductPage({ params, searchParams })
    render(Page)
 
    expect(screen.getByText('Product 123')).toBeInTheDocument()
  })
})
 
// Testing after API
import { after } from 'next/server'
 
jest.mock('next/server', () => ({
  after: jest.fn((fn) => fn()),
}))
 
describe('publishArticle', () => {
  it('sends notifications after publishing', async () => {
    const formData = new FormData()
    formData.append('title', 'Test Article')
    formData.append('content', 'Content here')
 
    await publishArticle(formData)
 
    expect(sendSubscriberNotifications).toHaveBeenCalled()
    expect(updateSearchIndex).toHaveBeenCalled()
  })
})

Future Outlook

Next.js 15 positions the framework for the React 19 era. The async request APIs align with React's streaming-first architecture. Turbopack's stability in development paves the way for production builds. The after API introduces server-side background processing patterns that will expand in future versions. Teams should upgrade to Next.js 15 to stay current with the React ecosystem and benefit from the performance improvements.

Next.js 15 Performance Monitoring

Monitor Next.js 15 application performance using the built-in useReportWebVitals hook and the Vercel Analytics dashboard. Track Core Web Vitals (LCP, FID, CLS, INP) across different page types and user segments. Use the Next.js Speed Insights to identify performance regressions after deployments. Profile server-side rendering performance using Node.js profiling tools to identify slow data fetching or component rendering. Enable React's Profiler API in development to measure component render times and identify optimization opportunities.

Next.js 15 Caching Strategy

Configure caching in Next.js 15 using the updated fetch caching semantics. By default, fetch requests in Next.js 15 are no longer cached automatically — you must explicitly opt in using cache: 'force-cache' or time-based revalidation. Use revalidate option on fetch calls to set cache duration per request. Implement static data fetching patterns with generateStaticParams for dynamic routes that should be pre-rendered. Use the unstable_cache function for non-fetch data sources like database queries or API calls that need caching.

Production Deployment and Monitoring

Deploying React applications to production requires careful consideration of build optimization, error tracking, and performance monitoring. A well-configured production build can significantly improve user experience through faster load times and more reliable error reporting.

Build Optimization Checklist

Before deploying, verify that your production build is fully optimized:

// next.config.js
module.exports = {
  reactStrictMode: true,
  poweredByHeader: false,
  compress: true,
 
  // Optimize images
  images: {
    formats: ['image/avif', 'image/webp'],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
    minimumCacheTTL: 60 * 60 * 24 * 30, // 30 days
  },
 
  // Security headers
  async headers() {
    return [{
      source: '/(.*)',
      headers: [
        { key: 'X-Frame-Options', value: 'DENY' },
        { key: 'X-Content-Type-Options', value: 'nosniff' },
        { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
      ],
    }];
  },
 
  // Webpack optimization
  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.optimization.splitChunks = {
        chunks: 'all',
        cacheGroups: {
          vendor: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendor',
            chunks: 'all',
          },
        },
      };
    }
    return config;
  },
};

Error Tracking Integration

Configure Sentry or a similar error tracking service to capture and categorize production errors:

import * as Sentry from '@sentry/nextjs';
 
Sentry.init({
  dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
  tracesSampleRate: 0.1,
  replaysSessionSampleRate: 0.1,
  replaysOnErrorSampleRate: 1.0,
  integrations: [
    new Sentry.BrowserTracing(),
    new Sentry.Replay({
      maskAllText: true,
      blockAllMedia: true,
    }),
  ],
  beforeSend(event) {
    // Filter out known non-critical errors
    if (event.exception?.values?.[0]?.type === 'ChunkLoadError') {
      return null;
    }
    return event;
  },
});

Health Check Endpoints

Implement health check endpoints that your load balancer and monitoring systems can use to verify application availability:

// pages/api/health.ts
export default async function handler(req, res) {
  try {
    // Check database connectivity
    await db.raw('SELECT 1');
 
    // Check external service dependencies
    const redisPing = await redis.ping();
 
    res.status(200).json({
      status: 'healthy',
      timestamp: new Date().toISOString(),
      services: {
        database: 'connected',
        redis: redisPing === 'PONG' ? 'connected' : 'degraded',
      },
      uptime: process.uptime(),
    });
  } catch (error) {
    res.status(503).json({
      status: 'unhealthy',
      error: error.message,
    });
  }
}

This comprehensive monitoring approach ensures you detect and respond to production issues quickly, maintaining high availability for your users.

Community Resources and Further Learning

The technology landscape evolves rapidly, making continuous learning essential for maintaining expertise. Building a systematic approach to staying current with developments in your technology stack ensures you can leverage new features and avoid deprecated patterns.

Curated Learning Pathways

Rather than consuming content randomly, create structured learning pathways aligned with your current projects and career goals. Start with official documentation and specification documents, which provide the most accurate and comprehensive information. Follow this with hands-on tutorials and workshops that reinforce concepts through practical application.

Technical blogs from framework maintainers and core team members often provide deeper insights into design decisions and upcoming features. Subscribe to the official blogs of your primary frameworks and libraries to stay ahead of breaking changes and deprecation timelines.

Contributing to Open Source

Contributing to open-source projects in your technology stack provides unparalleled learning opportunities. Start with documentation improvements and bug reports, then progress to fixing small issues tagged as "good first issue" in your favorite projects. This direct engagement with maintainers and the codebase accelerates your understanding far beyond what passive learning can achieve.

# Setting up for contribution
git clone https://github.com/project/repository.git
cd repository
git checkout -b fix/issue-description
 
# Run the project's contribution setup
npm run setup:dev
npm run test  # Ensure tests pass before making changes
 
# Make your changes, then run the full test suite
npm run test:full
npm run lint
npm run build
 
# Submit your contribution
git add -A
git commit -m "fix: description of the fix
 
Closes #1234"
git push origin fix/issue-description

Building a Technical Knowledge Base

Maintain a personal knowledge base that captures insights, solutions, and patterns you discover during your work. Tools like Obsidian, Notion, or even a simple Markdown repository can serve as an external memory that grows more valuable over time.

Organize your notes by topic rather than chronologically, and include code examples, links to relevant documentation, and explanations of why certain approaches work better than others. When you encounter a particularly insightful article or conference talk, write a summary that captures the key takeaways and how they apply to your current projects.

Follow key conferences and their published talks to stay informed about emerging patterns and best practices. Many conferences publish recorded talks on YouTube within weeks of the event, making world-class technical content freely accessible.

Join relevant Discord servers, Slack communities, and forums where practitioners discuss real-world challenges and solutions. These communities provide early warning about emerging issues and access to collective wisdom that isn't available through formal documentation.

Mentorship and Knowledge Sharing

Teaching others is one of the most effective ways to deepen your own understanding. Consider writing technical blog posts, giving talks at local meetups, or mentoring junior developers. The process of explaining concepts to others forces you to organize your knowledge and identify gaps in your understanding.

Pair programming sessions with colleagues of different experience levels create mutual learning opportunities. Senior developers gain fresh perspectives on problems they've solved the same way for years, while junior developers benefit from exposure to production-grade thinking and decision-making processes.

Conclusion

Next.js 15 stable brings meaningful improvements to caching transparency, development speed, and React 19 alignment. The shift to opt-in caching eliminates the most common source of confusion in Next.js 14. Turbopack delivers 4-27x development speed improvements. The after API enables background processing without blocking responses.

Key takeaways:

  1. Fetch requests are no longer cached by default—add explicit cache directives where needed
  2. Request APIs (cookies(), headers(), params, searchParams) are now async—use await
  3. Turbopack is stable for development—enable it for dramatically faster iteration
  4. The after API runs work after the response is sent—use for analytics, notifications, and background jobs
  5. Run the codemod first, then manually review caching and async API changes

For the complete migration reference, consult the Next.js 15 upgrade guide and React 19 migration guide.