Skip to content
Softronic
← Back to blog

React Server Components Are Now the Default — What That Means for Your App

RSC is no longer the experiment. How the mental model changes, what breaks in existing apps, and the migration pattern for teams on legacy React.

7 min read By Softronic reactrscnext-jsfrontend

For three years, React Server Components were “the new thing some teams were trying.” In 2026 that’s over. The Next.js App Router has been the default for two major versions. Remix merged into React Router and now ships RSC as a first-class primitive. TanStack Start went GA with RSC support. The React docs lead with Server Components.

The experiment is over. Server Components are the default mental model for React in 2026. Teams on the old client-only model are now the minority, and the documentation, tutorials, and library ecosystem are quietly moving on without them.

This is not a “ship it tomorrow” article. It’s a clear-eyed look at the mental shift, what actually breaks, and the migration path we’ve used on real client codebases.

The mental model shift

Old React: every component is a client component. State lives in the browser. Data fetching happens via useEffect or a client-side query library after the page hydrates.

New React: every component is a server component by default, rendered on the server, with zero JavaScript shipped to the client unless you explicitly opt in.

The opt-in is a single directive at the top of a file: 'use client'. That marks the file (and everything it imports) as a client component, which means it hydrates in the browser and can use hooks like useState, useEffect, browser APIs, and event handlers.

A practical example:

// app/dashboard/page.tsx — Server Component by default
import { db } from '@/lib/db';
import { RefreshButton } from './refresh-button';

export default async function DashboardPage() {
  const stats = await db.stats.findMany();
  return (
    <main>
      <h1>Dashboard</h1>
      <ul>
        {stats.map(s => <li key={s.id}>{s.label}: {s.value}</li>)}
      </ul>
      <RefreshButton />
    </main>
  );
}
// app/dashboard/refresh-button.tsx — explicitly a Client Component
'use client';
import { useTransition } from 'react';
import { revalidate } from './actions';

export function RefreshButton() {
  const [pending, start] = useTransition();
  return (
    <button onClick={() => start(() => revalidate())} disabled={pending}>
      {pending ? 'Refreshing…' : 'Refresh'}
    </button>
  );
}

The page component is a server component. It hits the database directly. It ships zero JavaScript for the list rendering. The button is a client component because it needs useTransition. The two compose cleanly.

This is the part that breaks people’s brains: the server component doesn’t ship to the browser at all. No bundle. No hydration. The HTML is rendered server-side, streamed down, and that’s it for that part of the tree.

What actually changes for your app

Data fetching collapses to async/await. No useQuery, no useEffect, no SWR around the data layer of your server components. You write:

export default async function Page() {
  const user = await getUser();
  const posts = await getPosts(user.id);
  return <Feed user={user} posts={posts} />;
}

That’s it. The function runs on the server. The fetch happens before any HTML is sent. The client never sees the data-fetching code.

Streaming is the new pattern for slow data. Wrap a slow component in <Suspense> and React streams the HTML in two passes: the fast stuff first, then the slow stuff replacing its fallback when ready. No more loading spinners blocking the entire page.

Server Actions replace most REST/GraphQL endpoints for internal mutations. A function annotated 'use server' can be imported into a client component and called like a local function — React handles the network round-trip transparently. For internal CRUD this eliminates a category of code (custom API routes, fetch boilerplate, hand-rolled types).

Forms get a real upgrade. The useFormStatus, useFormState, and useActionState hooks give you progressive-enhancement-friendly forms that work even before JavaScript loads.

What breaks in existing apps

This is the part the cheerful blog posts skip. Real migrations have real friction.

Context providers. If you have a top-level <ThemeProvider> or <AuthProvider> in your app shell, every component that uses the context becomes a client component. The boundary cascades. You can mitigate by lifting providers to the lowest layout that actually needs them, but you’ll often discover your context boundaries were sloppy.

Hooks that assume the browser. useEffect, useLayoutEffect, useState, useRef — all client-only. Any utility hook in your codebase that uses these has to be marked 'use client' or refactored. Codemod helps; auditing is still required.

Libraries that don’t declare boundaries correctly. Some popular libraries shipped without proper 'use client' annotations and break in subtle ways. The ecosystem is mostly fixed, but if you have older deps you’ll find a few. The error messages are not always obvious.

Direct DOM access. window, document, localStorage, IntersectionObserver. All browser-only. Code paths that reach for these need to be client components or wrapped in useEffect.

Date and time rendering. Server renders in UTC. Client renders in the user’s local time. Mismatch causes hydration errors. The fix is to render dates as ISO strings on the server and format them on the client, or to render dates with suppressHydrationWarning carefully.

Third-party scripts that mutate the DOM. Analytics, marketing tags, anything that adds nodes to <body> before React hydrates can cause hydration mismatches. Use the <Script> component (or your framework’s equivalent) and the afterInteractive strategy.

Authentication patterns. If your old app stored a JWT in localStorage and attached it to every fetch in a client interceptor, you’ll need to move auth to httpOnly cookies and read them on the server. This is a strict improvement security-wise, but it’s a refactor.

State management evolves

The old state management debate (Redux vs Zustand vs Jotai vs Context vs query libraries) gets simpler in an RSC world.

Server state lives on the server. No more shipping a query library + cache to the client just to fetch and re-fetch data. The server fetches, the client renders. For mutations, Server Actions cover most cases with revalidatePath or revalidateTag for cache invalidation.

Client state stays on the client, but there’s less of it. Form state, UI state (modals open, dropdowns expanded), optimistic updates. Zustand or Jotai still shine here. useReducer covers many cases that used to demand Redux.

Shared client state across routes. Still a real problem. The pattern that works: a thin Zustand store inside a 'use client' provider mounted at the layout that scopes the state.

The net effect: most apps need significantly less client-side state management code than they used to. We’ve migrated codebases where 40-60% of the Redux store disappeared because the data was actually server state, not client state.

A migration pattern that works

For an existing Next.js Pages Router app, the migration pattern that’s worked for our clients:

  1. Stay on Pages Router for now, but adopt App Router for new routes. Next.js supports both side-by-side. New features go in app/, old ones stay in pages/. No big-bang rewrite.
  2. Identify your “leaf” pages. Routes with the least cross-cutting concerns (a settings page, a static marketing route, a CRUD admin screen). Migrate these first.
  3. Lift providers as low as possible. Before migrating, audit your _app.tsx providers. Anything that doesn’t truly need to wrap the entire app should be pushed down. This reduces the client-component cascade.
  4. Migrate data fetching from getServerSideProps to async server components. Often this is a 10-20 line file getting cleaner.
  5. Convert API routes used only internally into Server Actions. External APIs (called by mobile clients, webhooks, etc.) stay as routes.
  6. Move route-by-route. Most teams take 2-4 months for a real app. The pace is set by how aggressive you can be about lifting providers and refactoring shared utilities.

For a Vite + React SPA migrating to RSC: the path is harder. You’re often better off picking a target framework (Next.js, Remix-on-React-Router, or TanStack Start) and porting incrementally behind a reverse proxy. We’ve done this on three codebases. Budget 1.5-3x what you think.

The common mistakes we see

  • Marking everything 'use client' “to be safe.” This defeats the entire point. The default should be server; client is the opt-in.
  • Fetching data in client components when the parent is a server component. Pass the data down as props. No useQuery needed.
  • Trying to use server-only secrets in code that ends up client-side. Read the bundle output to confirm. Use the server-only package to enforce.
  • Ignoring the cache. Next.js, Remix, and TanStack all have nuanced caching. Read the docs. The first hydration mismatch you hit will probably be a cache issue.
  • Skipping the migration of error and loading states. The error.tsx and loading.tsx conventions are not optional polish. They’re the UX primitives now.

When you’d want help

RSC is a real productivity unlock for teams that adopt it well, and a real productivity tax for teams that adopt it badly. The difference is whether you have someone on the team who’s done the migration before.

We’ve migrated mid-size React apps from Pages Router and from Vite SPAs over the last 18 months. We can come in as an embedded team for a 4-12 week migration, or as a tiger team that does the architecture, the codemods, and the first few route migrations, then hands the pattern to your team.

The mental model shift is the hard part. The code changes are mostly mechanical once your head is in the right place. Give your team the time to learn it properly, and you end up with an app that’s smaller, faster, and significantly less code than the version it replaced.

NEXT MOVE

Ship the next thing. Today.

Book a 30-minute call. We tell you within the call if we can help — including an honest "no" when we can't.