Skip to main content
ASoc
Tutorial

Next.js Force Client-Side Rendering: "use client" Doesn't, and the Build Shows It

"use client" still prerenders on the server. A probe build shows what ssr: false emits instead, and how useOwnedProducts keeps per-viewer UI off the static HTML.

The ASoc Team6 min read

"use client" does not force client-side rendering in Next.js: the component is still prerendered to HTML on the server and then hydrated. To skip the server render, load it with next/dynamic and { ssr: false } from inside a Client Component, or defer the work until after mount with useEffect. A build of a probe page on this repo proved the difference.

What "force client-side rendering" actually means

Two different things get called by that name, and they have different fixes.

You wantMechanismServer HTML contains
Interactivity (state, handlers)"use client"The component's full markup
The component to render only in the browserdynamic(..., { ssr: false })Its loading fallback only
One value that differs per visitoruseEffect after mountThe signed-out or default state
The whole route rendered per requestDynamic rendering (cookies, headers)Fresh HTML, still server-made

Most people asking this question want the second or third row. They hit a hydration warning or a window is not defined error and assume the directive was supposed to prevent it. It does not.

The experiment: what each option emits

To avoid guessing, a throwaway route was added to this repo, built with npm run build, inspected, and deleted. It rendered two components:

// Probe.tsx
"use client";
import { useEffect, useState } from "react";
export default function Probe({ label }: { label: string }) {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  return <p>PROBE-{label}-{mounted ? "mounted" : "prerender"}</p>;
}
// ClientOnly.tsx
"use client";
import dynamic from "next/dynamic";
const Probe = dynamic(() => import("./Probe"), {
  ssr: false,
  loading: () => <p>PROBE-skeleton</p>,
});
export default function ClientOnly() {
  return <Probe label="dynamic" />;
}

The page rendered <Probe label="plain" /> and <ClientOnly />. The prerendered .next/server/app/csr-probe.html contained:

<p>PROBE-<!-- -->plain<!-- -->-<!-- -->prerender</p>
<p>PROBE-skeleton</p>
BAILOUT_TO_CLIENT_SIDE_RENDERING
ComponentIn the server HTMLAfter hydration
"use client" onlyFull text, in its prerender statemounted
dynamic with ssr: falseThe loading skeleton, plus a BAILOUT_TO_CLIENT_SIDE_RENDERING markerThe component

So a plain Client Component runs on the server too. React splits its text with <!-- --> comment nodes, which is also why a naive grep for the whole string finds nothing.

Where the real code already does this

The honest case for client-only rendering is data that depends on who is looking. src/lib/useOwnedProducts.ts decides whether a template card shows a download menu for an owner. The page is static, so the server cannot know the viewer. The hook starts in a neutral state and resolves after mount:

const [state, setState] = useState<OwnedProducts>({
  status: COMMERCE_ENABLED ? "loading" : "ready",
  ...NOBODY,
});

useEffect(() => {
  if (!COMMERCE_ENABLED) return;
  let cancelled = false;
  void loadOwnership().then((result) => {
    if (!cancelled) setState({ status: "ready", ...result });
  });
  return () => {
    cancelled = true;
  };
}, []);

The lookup is memoized at module scope, because a templates grid mounts a dozen cards and each would otherwise ask the same question. It also checks hasAuthCookie() first, so an anonymous visitor or a crawler never downloads the roughly 68 KiB auth client. The hook's own comment states the limit: it is "a rendering convenience, never an authorization decision". Forcing the output client-side only changes what is drawn; /api/download still answers 403.

This pattern keeps the SEO surface intact. The server HTML is the signed-out card, which is what a crawler should index, and the owner-only controls appear after hydration.

Choosing the mechanism

CaseUseCost
Per-viewer UI on a static pageuseEffect + stateBrief flash of the default state
Library that touches window at import timedynamic(..., { ssr: false })No server HTML for that part
A widget below the folddynamic with ssr: false, plus a fixed-height loadingLayout shift if the fallback is the wrong size
A whole page behind a loginFetch in the client; keep the shell staticCrawlers see only the shell
Content you want indexedDo not force it client-sideCrawlers may see only a loading state

The Next.js docs shipped in node_modules/next/dist/docs/01-app/02-guides/lazy-loading.md state the constraint: ssr: false only works for Client Components and raises an error if used in a Server Component. That is why the probe wrapped dynamic in its own "use client" file.

Mistakes and troubleshooting

SymptomCauseFix
ssr: false is not allowed with next/dynamic in Server Componentsdynamic called from a Server ComponentMove the call into a file marked "use client"
window is not defined during buildBrowser API read during prerenderRead it in useEffect or load the component with ssr: false
Hydration mismatch warningInitial render read window, localStorage or Date.now()Render a neutral state first, set the real value in an effect
Content missing from view-sourceIt is client-only by designKeep indexable text out of client-only components
Layout jumps when the component appearsloading fallback has a different heightGive the fallback the final dimensions
One request per card for the same dataState fetched in each componentMemoize one promise at module scope, as useOwnedProducts does

Frequently asked questions

Does "use client" disable server-side rendering? No. It marks a module boundary for the bundle. The component is still rendered to HTML on the server, and then hydrated in the browser.

How do I render a component only in the browser in the App Router? Wrap it with next/dynamic and { ssr: false } inside a Client Component. The server HTML then contains only the loading fallback.

Is client-side rendering bad for SEO? It can be. The Next.js docs warn that some crawlers may not run JavaScript and would then see only a loading state. Keep content you want indexed in the server HTML.

Can I make a whole route client-rendered? Not with a single flag that this build documents. The common route is a static or server shell that renders one ssr: false Client Component for the app body.

Templates in this post

ASoc Ignite is an AI-app marketing site with a capability showcase and a projects portfolio. ASoc Iris is a computer-vision studio site with case studies and pricing. ASoc Keystone is a mortgage-lender marketing site with an eligibility checker and a quote funnel.

Keep reading

Tutorial7 min read

Next.js force-dynamic: What It Costs and When You Need It

One line turned a 112 KiB static page into a server render, 820 prerendered pages to 819. Measured in a real build, plus the 8 routes that go dynamic with no config.

Read more
Tutorial13 min read

Gated File Downloads in Next.js: The Order the Checks Must Run In

Authenticate, verify, validate, authorize, rate-limit and record — then sign. The atomic limiter that closes the count-then-insert race, and what fails closed versus open.

Read more