The Upgrade That Broke More Than You Expected
A few months ago I pushed a Next.js 14 app through the upgrade guide, ran npm install, and watched the build fail in five different ways at once. Three of those failures were things I had confidently told a colleague "should be fine."
React 19 and Next.js 15 are not a routine patch cycle. They are the framework catching up to a mental model that has been shifting for two years: data fetching as a first-class primitive, forms as stateful transitions, and the server as a legitimate rendering peer rather than an afterthought. If you approach the upgrade expecting a handful of import renames, you will lose an afternoon. If you understand what each change is actually solving, you can migrate a real production app in a day or two with high confidence.
This post is for engineers running React 18 and Next.js 14 in production who want an honest account of what moved, what broke, and what actually got better.
What React 19 Actually Changed
React 19 shipped four things that matter day-to-day.
Actions. The new useActionState hook (previously called useFormState in the canary channel, then renamed before stable release) ties form submission to async state. The transition happens inside React, which means concurrent rendering and optimistic updates come for free.
use() is now a real hook. You can call use(promise) or use(context) conditionally, which was previously a rules-of-hooks violation. This is the primitive Server Components use to stream data into the client tree.
ref as a prop. forwardRef is removed. You pass ref directly to a function component just like any other prop. The wrapper was always boilerplate; React 19 eliminates it.
useOptimistic. Optimistic UI state that automatically rolls back when the underlying action settles. No manual state machine, no finally block reverting flags.
None of these require a Next.js app. They work in any React 19 app, including Vite or CRA (if you are still on CRA, that is a separate problem).
Next.js 15 Caching: The Part That Will Bite You
Next.js 14 cached aggressively by default. fetch() inside Server Components was cached by the framework unless you explicitly opted out. The assumption was that most requests should be cached; you opted out per-request or per-route.
Next.js 15 reversed this. Fetch requests are no longer cached by default. Routes with no dynamic functions are still statically rendered at build time, but individual fetch calls do not get automatic force-cache semantics. You opt in to caching rather than opting out.
This is the change most likely to break your existing app silently. Your pages may still render, but you will start making far more upstream requests than before.
// Next.js 14 behaviour: cached by default
const data = await fetch('https://api.example.com/products')
// Next.js 15: same call, now uncached. Add the option explicitly:
const data = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 }, // revalidate every hour
})
// Or use the Request cache option for a permanent static cache:
const data = await fetch('https://api.example.com/products', {
cache: 'force-cache',
})The corollary: if you were relying on the old default and adding { cache: 'no-store' } only for dynamic data, go through every Server Component fetch in your codebase and decide explicitly. Treat "no cache option" as "no cache" from now on.
The Actions API in Practice
Before React 19, form handling in Next.js looked like a useState flag, a useEffect to reset it, an onSubmit handler, and probably a separate loading boolean. The pattern worked but it composed poorly, especially across Server and Client Components.
React 19 introduces useActionState for client-side forms and Server Actions for mutations that run on the server. The two are complementary, not competing.
Here is a contact form with client-side validation feedback:
'use client'
import { useActionState } from 'react'
type FormState = {
error: string | null
success: boolean
}
async function submitContact(
prevState: FormState,
formData: FormData
): Promise<FormState> {
const email = formData.get('email') as string
const message = formData.get('message') as string
if (!email || !message) {
return { error: 'All fields are required.', success: false }
}
const res = await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify({ email, message }),
headers: { 'Content-Type': 'application/json' },
})
if (!res.ok) {
return { error: 'Failed to send. Try again.', success: false }
}
return { error: null, success: true }
}
export function ContactForm() {
const [state, action, isPending] = useActionState(submitContact, {
error: null,
success: false,
})
if (state.success) {
return <p>Message sent. We will get back to you soon.</p>
}
return (
<form action={action}>
<input name="email" type="email" placeholder="you@example.com" />
<textarea name="message" placeholder="Your message" />
{state.error && <p role="alert">{state.error}</p>}
<button type="submit" disabled={isPending}>
{isPending ? 'Sending...' : 'Send'}
</button>
</form>
)
}Notice that action is passed directly to the <form> element's action prop. React 19 extends the meaning of the action attribute on forms and buttons so that an async function works there natively, without a custom onSubmit handler.
The isPending flag is managed by React's concurrent mode; you do not need a separate state variable. The previous state is threaded through as the first argument, which is how useActionState lets you accumulate or replace state across submissions.
Server Actions and Mutations
Server Actions are async functions marked with 'use server' that run exclusively on the server, even when called from a Client Component. They were introduced in Next.js 14 but stabilised in Next.js 15, which also changed how revalidation works after an action completes.
// app/actions/posts.ts
'use server'
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const body = formData.get('body') as string
if (!title || !body) {
throw new Error('Title and body are required.')
}
await db.post.create({ data: { title, body } })
// Tell Next.js to regenerate the posts listing page
revalidatePath('/posts')
}And the Client Component that calls it:
'use client'
import { useActionState } from 'react'
import { createPost } from '@/app/actions/posts'
export function NewPostForm() {
const [error, action, isPending] = useActionState(
async (_prev: string | null, formData: FormData) => {
try {
await createPost(formData)
return null
} catch (e) {
return (e as Error).message
}
},
null
)
return (
<form action={action}>
<input name="title" placeholder="Post title" required />
<textarea name="body" placeholder="Content" required />
{error && <p role="alert">{error}</p>}
<button disabled={isPending}>
{isPending ? 'Posting...' : 'Publish'}
</button>
</form>
)
}One thing to know about Server Actions: the network boundary is transparent, which means the function call looks local but is actually a POST request to an internal Next.js endpoint. You get type safety across the boundary because TypeScript sees the same function signature, but serialisation constraints apply: you can only pass FormData, primitives, or plain objects, not class instances or non-serialisable values.
useOptimistic for Snappy UI
Optimistic updates used to mean: maintain two state variables, apply the optimistic value immediately, revert on failure. The boilerplate was repetitive enough that most teams skipped it and just showed a spinner.
useOptimistic wraps the pattern:
'use client'
import { useOptimistic, useActionState } from 'react'
import { toggleLike } from '@/app/actions/likes'
type LikeButtonProps = {
postId: string
initialCount: number
initialLiked: boolean
}
export function LikeButton({ postId, initialCount, initialLiked }: LikeButtonProps) {
const [optimisticState, addOptimistic] = useOptimistic(
{ count: initialCount, liked: initialLiked },
(current, action: 'toggle') => ({
count: action === 'toggle' ? current.count + (current.liked ? -1 : 1) : current.count,
liked: action === 'toggle' ? !current.liked : current.liked,
})
)
const [, action] = useActionState(
async (_prev: null, _: FormData) => {
addOptimistic('toggle')
await toggleLike(postId)
return null
},
null
)
return (
<form action={action}>
<button type="submit">
{optimisticState.liked ? 'Unlike' : 'Like'} ({optimisticState.count})
</button>
</form>
)
}If toggleLike throws, React rolls the optimistic state back automatically. If it succeeds, the real state from the server replaces it on the next render. The developer does not write the rollback logic.
Removing forwardRef: The Quiet Win
This is the smallest change in surface area but the most satisfying to clean up. Every component that accepted a ref previously needed the forwardRef wrapper:
// React 18
import { forwardRef } from 'react'
const TextInput = forwardRef<HTMLInputElement, { label: string }>(
({ label }, ref) => (
<label>
{label}
<input ref={ref} />
</label>
)
)
TextInput.displayName = 'TextInput'React 19 makes this unnecessary. ref is now a first-class prop:
// React 19
type TextInputProps = {
label: string
ref?: React.Ref<HTMLInputElement>
}
function TextInput({ label, ref }: TextInputProps) {
return (
<label>
{label}
<input ref={ref} />
</label>
)
}The React 19 codemod handles this automatically. Run it before you touch anything manually:
npx codemod@latest react/19/replace-use-form-state
npx codemod@latest react/19/replace-string-refThere is also a codemod for useContext to use(context), though the old API still works and the migration is optional.
The Gotcha That Caught Me: Async Params in Next.js 15
Next.js 15 made params and searchParams asynchronous in page and layout components. In Next.js 14, these were plain objects you could destructure synchronously. In 15, they are Promises.
This is the change I wish I had read more carefully before upgrading. The type signatures look almost the same and the runtime error you get is not always obvious about what caused it.
// Next.js 14
export default function ProductPage({
params,
}: {
params: { slug: string }
}) {
// params.slug is a string, usable immediately
return <h1>{params.slug}</h1>
}
// Next.js 15: params is now a Promise
export default async function ProductPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
return <h1>{slug}</h1>
}The same applies to searchParams:
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ q?: string }>
}) {
const { q } = await searchParams
return <p>Results for: {q ?? 'everything'}</p>
}This affects every page and layout that reads params or searchParams. If you have a large Next.js 14 app with many dynamic routes, this will be the most mechanical part of the migration. The Next.js codemod handles the straightforward cases:
npx @next/codemod@canary upgrade latestBut it will not always get the types right in complex layouts with deeply nested params. Audit any layout that passes params down to a child component.
Practical Migration Checklist
Here is the order of operations that caused me the least pain:
Step 1: Upgrade dependencies and run codemods.
npm install react@19 react-dom@19 next@15 eslint-config-next@15
npx @next/codemod@canary upgrade latestCheck the codemod output carefully. It will flag things it could not auto-fix.
Step 2: Fix async params. Search for every params and searchParams usage in page.tsx and layout.tsx files. Make the component async if it is not already, and await the destructure.
grep -r "params\." app/ --include="*.tsx" -lStep 3: Audit fetch caching. Every fetch call in a Server Component now needs an explicit cache option. Decide between revalidate: N (ISR), force-cache (static), or no option (dynamic/uncached). Do not guess; look at what data each route actually displays and how fresh it needs to be.
Step 4: Replace useFormState with useActionState. The old import is from react-dom; the new one is from react. The function signature is the same.
// Before
import { useFormState } from 'react-dom'
// After
import { useActionState } from 'react'Step 5: Clean up forwardRef. Run the codemod, then do a manual pass on any components the codemod missed (particularly ones that combine forwardRef with generics, which the codemod sometimes misses).
Step 6: Test your error boundaries. React 19 changed how errors propagate in some edge cases, particularly around suspense boundaries that wrap async Server Components. If you have custom error boundaries, test them explicitly.
Tradeoffs and Honest Caveats
The Server Actions model is elegant but it does add a layer of abstraction over what is really just an HTTP POST. When something goes wrong in a Server Action, your stack trace arrives in the server logs, not the browser console. Tooling has improved a lot here but debugging still requires comfort with both environments simultaneously.
The Actions pattern also makes it easy to accidentally do too much in one action. Because the function runs on the server, it is tempting to do database writes, send emails, and call external APIs all in one place. Keep actions focused on a single mutation. If you need multiple effects, call them from a dedicated service layer rather than piling everything into the action function.
On caching: the shift to opt-in is the right default for correctness, but it will increase your origin request volume if you migrate without reviewing each fetch. Monitor your API error rates and response times for a week after migrating. Tools like Datadog APM or even a simple Next.js custom instrumentation.ts can surface this.
// instrumentation.ts (Next.js 15, runs on server start)
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
// Attach any server-side monitoring init here
const { initMonitoring } = await import('./lib/monitoring')
initMonitoring()
}
}Finally, the use() hook is powerful but its use in client components for streaming promises needs care. If the promise is not stable across renders (for example, created inline in the component body), you will trigger infinite re-renders. Memoize or hoist the promise source.
Key Takeaways
The React 19 and Next.js 15 combination is a coherent upgrade, not a collection of isolated features. The common thread is explicit control: explicit caching, explicit server boundaries, explicit transition state. The framework is giving you more precision and less magic.
If I were picking the single most impactful thing to adopt first after upgrading, it is useActionState paired with Server Actions. The forwardRef cleanup is satisfying but low priority. The caching audit is highest priority for production correctness. The useOptimistic hook is a quality-of-life improvement you can roll out gradually.
Concrete steps to take away:
- Run
npx @next/codemod@canary upgrade latestbefore touching anything manually. - Audit every
fetchin Server Components and set an explicit cache policy. - Await
paramsandsearchParamsin all pages and layouts; make the components async. - Replace
useFormState(react-dom) withuseActionState(react). - Remove
forwardRefwrappers; passrefas a regular prop. - Test error boundaries and suspense fallbacks explicitly after upgrading.
- Monitor origin request volume post-migration; the caching default change is the most likely source of unexpected load.
The upgrade is real work. It is also worth it. The resulting codebase is cleaner, the data layer is more predictable, and the form handling story is finally first-class rather than a collection of community patterns bolted onto a rendering library.