The context: twelve months later, the real scorecard
In January 2025 we published a Next.js 14-to-15 migration guide. Twelve months later, three applications run RSC-by-default in production at VALRY LABS: the institutional website, a client SaaS back-office, and an internal lead-management platform. The field has spoken.
The overall verdict fits in one line: the promise of performance and architectural simplicity held true at 70%. The remaining 30% is the client/server boundary tax, a library ecosystem still aligning itself, and a few blind spots the official documentation only half illuminates.
This article is not a tutorial. It is a construction-site debrief: the bugs that ate our sprints, the numbers we measured in real conditions, and the internal rules we locked in so we never repeat the same mistakes.
What broke in practice: serialisation, cycles, `use cache`
The most frequent trap remains the serialisation boundary. Passing a `Date` object or a `Map` from a Server Component to a Client Component triggers the `only plain objects, and a few built-ins, can be passed to Client Components` error. We hit it 23 times in twelve months, twice escalating to production because the parent type didn't explicitly expose a buried `Date` field.
The second recurring incident: accidental client/server import cycles. A client component importing a server module (typically a helper that calls Prisma) passes the build, then explodes at runtime with `Cannot read property of undefined`. The internal rule is now strict: any file touching `lib/infra/` or `lib/application/` may never appear in the import graph of a component marked `"use client"`. A custom ESLint rule checks this in CI.
Finally, `use cache` (stable since React 19.1) has its own footguns. We watched a marketing route serve stale content for 4 hours because a developer had put the directive on a function that consumed `headers()` dynamically. The rule: `use cache` only on pure functions with no request dependency, never on business logic that reads `cookies()` or `headers()`.
On Next 15, the other subtle collision involves the dynamic APIs (`cookies()`, `headers()`, `params` now async) combined with PPR. A page marked `experimental_ppr = true` that calls `await cookies()` in a child segment breaks the static prerender and silently flips the route to dynamic. Watch for it via the `next build` output, which lists the opt-out reasons.
What positively surprised us: bundle, INP, auth
The client bundle of the institutional website went from 187 kB to 119 kB gzipped (-36%) between the Next 14 version and the current full-RSC one. The gain comes less from RSC itself than from the disappearance of fetching libraries (`swr`, `react-query`) on marketing surfaces, now read server-side. The richer client back-office dropped from 312 kB to 241 kB (-23%).
On INP, the 28-day mobile median went from 184 ms to 96 ms on the home page, and from 230 ms to 128 ms on the blog listing (75th percentile). The main reason is the massive reduction in hydrated components. Fewer React trees to hydrate, fewer event listeners attached during `TBT`, and mechanically better INP. On mid-range mobiles (Pixel 4a), the gain is even more visible.
The third unexpected win concerns authentication. On Next 14, we cascaded `getServerSession` through a Client Provider. In RSC, we read `cookies()` directly in the root Server Component, validate the Supabase JWT server-side via `lib/infra/auth`, and pass a typed `session` object as a prop to the client islands that need it. No more Provider, no more unauthenticated-content flash, and half the auth code.
The discipline of `"use client"`: where to place the boundary
The rule we now apply: a component becomes a client component if and only if at least one of four criteria holds. Local state (`useState`, `useReducer`), effects (`useEffect`, `useLayoutEffect`), event handlers (`onClick`, `onSubmit`), or browser APIs (`window`, `matchMedia`, `IntersectionObserver`). Framer Motion also requires the marker, which pushes animations into dedicated wrappers rather than marking whole sections.
The costliest mistake observed: placing `"use client"` at the top of a parent file that imports an entire feature. One line flips dozens of components into the client bundle. Our convention is to push the marker as close to the leaf as possible: an animated `Button` stays client, but its parent `Card` stays server and simply includes the `Button`. The cost of misplacement shows in First Load JS — a badly placed marker can add 30 to 80 kB gzipped.
The pathological case is the non-serialisable prop escaping to production. A developer passes a `Date` to a client component thinking it's allowed; the build passes in dev, then crashes in production with a cryptic message. We added a type test that checks in CI that every `"use client"` component only accepts JSON-serialisable props (via a `Serializable<T>` utility applied to props interfaces).
Finally, be wary of third-party libraries that put `"use client"` at the root. Several UI-kit vendors popularise this pattern, which cancels the RSC benefit. At VALRY LABS, we mandate a UI dependency audit: if a library offers no RSC-friendly mode (Server Components as the default), we look for an alternative or isolate it in a minimal client wrapper.
Anti-patterns observed in production
The first anti-pattern is server-to-client-island prop drilling. A Server Component fetches 40 KB of data, passes it through four levels of components to feed one client chart. Result: bloated HTML payload, costly serialisation, and a tree that's hard to refactor. The rule: if a piece of data is consumed only by a client island, fetch it in the island via a dedicated Server Action, or better server-side with `fetch` and `cache: 'force-cache'`.
The second is server/client/server ping-pong via Server Actions. A form calls a Server Action that revalidates a page, which triggers a server refetch, which notifies a client via Realtime. In a back-office, this pattern can generate up to 6 round trips per interaction. We cap it at 2 (action + targeted revalidation), and ban generic `revalidatePath('/')` in favour of precise `revalidateTag('resource')`.
The third anti-pattern, subtler: fetching inside a Client Component (`useEffect` + `fetch`) when a Server Component would do. This happens when a developer converts a Next 14 page without rethinking the architecture. The typical trace is a Client Component fetching static data on mount, with a blinking loading spinner. Refactoring to RSC removes the spinner, the client-side fetch, and bakes the data directly into the served HTML.
Server Actions in production: idempotency, UX, errors
Server Actions replaced 90% of our back-office POST Route Handlers. The DX gain is real: less boilerplate, centralised Zod validation, typed return via `ActionState`. But in production, three problems recur: idempotency, network resilience, and error UX.
On idempotency: a user can double-click before `useFormStatus` disables the button. On a non-idempotent action (lead creation, email sending), that generates duplicates. Our convention is to inject an `idempotencyKey` (client-generated UUID v4) into the payload, stored in the database with a unique constraint. A second submission with the same key returns the first result. Cost: one column, one index, zero duplicates.
On network resilience: what happens when the network drops mid-action? Without a guardrail, the user gets an infinite `pending` state. We attach an `AbortController` with a 12-second client-side timeout, and a segment-level `error.tsx` that captures failures. Server-side, the action must be transactional by design: either it succeeds, or it rolls back via a Prisma `$transaction`. Never a partial write.
On error UX, `useActionState` cleanly separates success/error from the result. For optimistic UI, we combine `useOptimistic` with an explicit rollback strategy: apply the mutation locally, and on server failure restore the previous state from a snapshot. For sensitive mutations (payment, permanent deletion), we disable optimism and wait for server confirmation before updating the UI.
Migration advice for teams still on Next 14
The migration path starts with the official codemod: `npx @next/codemod@latest upgrade`. It correctly handles the signature changes (`params` becoming `Promise<{...}>`, async `cookies()` and `headers()`), the `next/headers` imports, and a few renames. Across our three applications, the codemod handled 70 to 80% of the mechanical work. The rest is manual.
What the codemod doesn't handle: implicit Server Components that break because they use hooks, components passing functions as props (now forbidden across the client/server boundary), and third-party libraries that aren't React 19 compatible. Budget 2 to 5 dev-days per application to handle these cases by hand, plus 1 day of dependency review (check the minimum versions of `swr`, `react-query`, Radix, etc.).
Then comes the post-migration monitoring window. We mandate 7 calendar days in production with enhanced surveillance before declaring the migration closed. Monitored indicators: Sentry (any new error spike > 2x baseline), CrUX (LCP, INP, CLS per device), Better Stack (server logs, Server Action latency), Plausible (form conversion rates). A major incident inside this window triggers an immediate rollback, not a hotfix.
Finally, anticipate the human trap: the learning curve of the `"use client"` boundary. Developers used to the all-client Next 14 world will scatter markers by reflex. Plan a 2-hour training session, a cheat sheet next to the repo, and attentive code review for the first 30 days. Once the discipline sets in, productivity returns to its previous level — and often above it, thanks to simpler auth and fetching.