Oct 03, 2025·6 min read

Next.js App Router server and client component mixups: fixes

Learn how to spot Next.js App Router server and client component mixups that cause runtime crashes, and how to restructure components, actions, and data fetching.

Next.js App Router server and client component mixups: fixes

What is a server-only vs client-only mixup?

A server-only vs client-only mixup happens when code runs in the wrong place.

In Next.js App Router, some components are meant to run on the server (safe for secrets, direct database calls, private APIs). Others are meant to run in the browser (button clicks, local state, access to window). When those responsibilities get tangled, you’ll see crashes, hydration issues, or builds that fail only after deployment.

A useful mental model:

  • Server Components fetch and prepare data, then pass plain props down.
  • Client Components handle interaction, but shouldn’t pull in server-only code.

It often looks fine in development because dev mode can be more forgiving. Hot reload, different bundling, and timing can hide boundary problems. Production builds are stricter about what can ship to the browser, and hydration is less tolerant of mismatches.

Common symptoms:

  • Blank page after navigation (error only in the console)
  • Hydration errors where the UI flashes, then breaks
  • Unexpected 500 errors during render
  • “window is not defined” or “document is not defined”
  • Build errors about importing server-only modules into client files

AI-generated code makes this more common because it copies snippets without respecting boundaries. A typical example: adding 'use client' to a whole page to fix a hook error, even though that page imports a database helper or reads secrets.

How App Router splits Server and Client Components

In the App Router, every component is a Server Component by default. That single rule explains most surprises.

Server Components (default)

Server Components run on the server. Use them for data fetching, reading cookies/headers, using environment secrets, and any heavy work you don’t want in the browser.

If it touches your database, private API keys, or an auth session, keep it on the server and pass the results down as props.

Client Components (opt-in)

A component becomes a Client Component only when you add "use client" at the top of the file. Client Components run in the browser, so they can use state, effects, event handlers, and browser-only APIs like localStorage.

The boundary works like this:

  • A Server Component can import a Client Component. Everything under that Client Component runs client-side.
  • A Client Component cannot import a Server Component or server-only modules.

You usually need "use client" when a component uses hooks like useState/useEffect, browser events like onClick, or browser APIs like window and document.

You don’t need "use client" for plain UI that just renders props. A common fix (especially in AI-generated prototypes) is to keep the page and data loading on the server, then render a small Client Component only for the interactive part (a filter, modal, or inline editor).

Runtime crash patterns you can recognize quickly

Most App Router crashes come down to one problem: browser-only code runs on the server, or server-only code gets bundled into the browser.

Pattern 1: Hooks in a Server Component

If you see errors like “React Hook ... is not supported in Server Components” or “You're importing a component that needs useState/useEffect,” check the file header. If the file doesn’t start with "use client", React treats it as a Server Component.

Pattern 2: Server-only modules pulled into client code

Errors mentioning fs, path, crypto, or “Module not found: Can't resolve 'fs'” often mean a client component imported a shared helper that (maybe indirectly) imports Node-only code.

This frequently happens when a shared utils or lib file mixes server and client helpers, and the client imports it “just for one function.”

Pattern 3: Browser-only APIs used during server render

“window is not defined,” “document is not defined,” and “localStorage is not defined” mean the code is running on the server. That might be a Server Component, a server action, or even a module that gets imported during server render.

Pattern 4: Calling server logic from the client without a safe bridge

These show up as “You're importing a Server Action into a Client Component,” “Server-only module cannot be imported from a Client Component,” or a client-side call that accidentally hits a function that was never meant to run in the browser.

Step by step: find the bad boundary in your component tree

The fastest wins come from making the crash repeatable. Try it in dev and then in a production build. “Works in dev” isn’t a pass for boundary issues.

When you read the error, stop at the first file you actually own. Framework stack frames are noisy. The first file in your repo is usually where the wrong import or component type enters the tree.

A simple workflow:

  • Reproduce the crash the same way every time (same route, same action, same user state).
  • In the stack trace, jump to the first app file and note which component rendered it.
  • Check the file header: is it a default Server Component, or does it start with "use client"?
  • Follow imports until you find the first mismatch:
    • server-only import used by client code (fs, database clients, next/headers)
    • browser-only usage inside server code (window, document, localStorage)
  • Decide ownership: secrets and data on the server, UI state and events on the client.

A very common failure: a server page passes a database client, cookies-derived data, or a server-only helper into a client component. That will break. Fetch on the server, then pass plain JSON data down.

Restructuring components that mix server and client concerns

Crashes usually happen when one component tries to do everything.

A reliable split:

  • Server Component: fetches data, checks auth, uses secrets.
  • Client Component: handles state, events, effects, and any DOM-dependent UI.

Move data work up the tree. Fetch in a Server Component (or a server-only function called by it), then pass the result down as plain props. This keeps server-only code out of the browser bundle.

Then isolate interactivity. Keep client pieces small so you don’t ship your whole page to the browser just to make one button work.

At the server-to-client boundary, keep props boring: strings, numbers, booleans, arrays, plain objects. Don’t pass database clients, request objects, class instances, or functions.

Example: a dashboard page fetches user info, subscription, and recent activity, but also has filters, a modal, and a chart. Fetch everything in DashboardPage (server) and pass { userName, plan, activityItems } down. Let a DashboardControls client component own the filter state and open/close the modal.

Server Actions: safe patterns for forms and mutations

Fix AI-generated Next.js apps
If Lovable, Bolt, v0, Cursor, or Replit output is breaking, we make it reliable.

Server Actions work well when a user submits a form and you need to change data: create a record, update a profile, reset a password, or run a small workflow.

A safe structure is to keep the form UI in a Client Component, and keep the mutation in a server-only file as an exported action. The client owns inputs, loading state, and displaying errors. The server owns authentication checks, validation, and database calls.

// actions.ts
'use server'

export async function updateProfile(formData: FormData) {
  const name = String(formData.get('name') ?? '')
  // validate, check auth, write to DB
  return { ok: true }
}

On the client side, pass only what the server needs. Don’t pass secrets, tokens, or raw user objects through props just to make an action work. If the action needs to know who the user is, read that on the server (cookies/session) inside the action.

Two habits prevent most leaks:

  • Validate inputs and re-check authorization inside the action.
  • Return safe, user-friendly errors, not stack traces.

If you want optimistic UI, keep it local and small. Avoid turning an entire page into a Client Component just to show a spinner.

Data fetching in App Router without double work

Many boundary problems start with fetching data in two places.

In App Router, the default should be server fetching close to the route. You’ll get a faster first paint, smaller browser bundles, and you keep secrets out of the client.

Fetch on the client only when you truly need it (polling, real-time widgets, or a refresh button that updates just one section).

A common bug: server render fetches data, then a Client Component mounts and fetches the same data again with useEffect. That can cause flicker, rate-limit issues, and confusing mismatches.

A clean flow looks like this:

  • Request hits a route
  • Server fetch gets the data (DB, internal API, or third party)
  • Server Components render the page with that data
  • Client Components handle interactions and trigger targeted updates

Caching can also hide problems during testing. If data looks randomly stale, check whether your fetch is cached and whether revalidation is set the way you expect.

Auth and secrets: what must stay on the server

Security check for AI code
We patch common risks like exposed secrets and SQL injection in AI-generated code.

Auth issues often start as a boundary mistake: a Client Component touches something that should never leave the server. Sometimes you get crashes or odd redirects. Sometimes you quietly leak secrets into the browser bundle.

The most common leaks in generated code:

  • Reading environment variables in a Client Component
  • Putting config in a shared file imported by both server and client

If it would be painful to see in DevTools, it shouldn’t be reachable from client code.

Keep authentication checks and role logic on the server. The client can render UI states, but it shouldn’t be the source of truth for “is this user allowed?”

Avoid storing sensitive tokens in localStorage by default. It’s easy to inspect and can be stolen by XSS.

Quick breakages to watch for:

  • Redirect loops when both server and client try to guard the same route
  • Session mismatches where the server renders one state and the client hydrates into another
  • Edge vs Node runtime confusion for auth libraries
  • “Works locally, breaks in prod” when env vars differ and client bundles change

Common mistakes that keep the crashes coming back

Most repeat crashes aren’t mysterious framework bugs. They’re the same boundary mistakes, patched in a hurry, then reintroduced.

A few patterns show up over and over:

  • Adding "use client" to a big page to silence a hook error
  • Keeping “shared” helpers that mix server-only and client-safe code
  • Creating or importing a database client inside component files (it spreads through imports fast)
  • Calling fetch() to your own API route from a Server Component out of habit, even when you can call server code directly
  • Fixing by trial and error instead of following the first bad import in the stack trace

A typical example: a dashboard page crashes only in production because it imports getUser() (reads cookies, server-only), but the page was marked "use client" to support a chart. The durable fix is to move the chart into its own Client Component and keep the page server-first.

Quick checklist before you ship

Most App Router crashes happen because one file is doing two jobs.

Boundary sanity check

Ask for every component: can this file run in the browser?

If yes, it must not touch secrets, server-only environment variables, database clients, or Node-only libraries. If you see those imports, move that work into a Server Component, a Server Action, or a server route.

Final sweep:

  • Browser APIs (window, document, localStorage, navigator) and hooks mean Client Component. Keep server logic out.
  • Secrets and server-only imports mean Server Component. Pass only the data the UI needs.
  • Props that cross the boundary should be serializable (plain objects, arrays, strings, numbers). Avoid class instances, BigInt, and functions.
  • For writes (forms, updates, deletes), use a Server Action or a server route.
  • Test a production build locally, not just next dev.

One practical habit

Before you ship, click through your main flows after a clean build. If a page crashes only in production mode, it’s usually a boundary problem, a non-serializable prop, or a server-only import leaking into the client.

Example: fixing a crashing dashboard page

Ship with fewer surprises
We make sure your Next.js build, hydration, and runtime match production expectations.

A classic situation: a dashboard page needs server-fetched user data plus interactive filters (date range, status toggles, search).

What went wrong

The first version often mixes concerns in one file. For example, app/dashboard/page.tsx fetches user data on the server, but also uses useState, reads localStorage, or calls window.matchMedia to remember filter settings. That works in the browser, but the page is a Server Component by default, so it can crash with “window is not defined” or “Hooks can only be used in a Client Component.”

Another common slip: the filter UI is marked with 'use client', but it imports a server-only helper that reads cookies or hits a private database. That can trigger “You’re importing a Server Component into a Client Component” style errors.

A simple restructure that stops the crashes

Make the page own data, and the client component own interactivity.

On the server (page): fetch data and render it.

// app/dashboard/page.tsx (Server Component)
import Filters from './Filters';
import { getDashboardData } from './data';

export default async function Page() {
  const data = await getDashboardData();
  return (
    <>
      <Filters initial={data.filters} />
      {/* render table using data.items */}
    </>
  );
}

On the client (filters): keep state and UI events local, and send changes through a Server Action.

// app/dashboard/actions.ts
'use server';
export async function updateFilters(next) {
  // validate input, save, return safe data
  return { ok: true };
}

Result: fewer runtime crashes, clearer ownership (server fetches and secrets stay server-only, client handles clicks), and updates go through one safe path.

Next steps if your App Router code keeps breaking

If you’re fighting the same crash again and again, it’s usually a project-wide boundary problem: client code pulls in server-only modules, server code imports hooks, or mutations end up scattered across the client.

AI-generated codebases from tools like Lovable, Bolt, v0, Cursor, or Replit tend to repeat these mistakes because they mix patterns that worked in older setups but don’t hold up under App Router’s stricter separation.

When the same symptoms show up across multiple pages, a focused refactor is often faster than patching.

If you inherited a broken AI-generated prototype and want a quick, structured diagnosis, FixMyMess (fixmymess.ai) specializes in repairing and hardening these kinds of Next.js codebases, starting with a free code audit to identify the first boundary mistakes and risky imports.